Files
TerminalBasic/crates/tb-ui/src/frm.rs

2271 lines
76 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text- und Importformat fuer Visual-Basic-for-DOS-Formulare.
use std::collections::{BTreeMap, HashSet};
use std::fmt;
use tb_frontend::forms::{
self, FormCatalog, ObjectClass, PropertyDefault, PropertySpec, PropertyType,
};
use crate::forms::{FormsModel, PropertyValue};
#[derive(Debug, Clone, PartialEq)]
pub struct FormNode {
pub class: ObjectClass,
pub name: String,
pub properties: BTreeMap<u16, PropertyValue>,
pub children: Vec<FormNode>,
}
#[derive(Debug, Clone)]
pub struct FormFile {
pub version: String,
pub root: FormNode,
pub code: String,
original: Option<Original>,
}
#[derive(Debug, Clone)]
struct Original {
version: String,
root: FormNode,
code: String,
bytes: String,
}
impl PartialEq for FormFile {
fn eq(&self, other: &Self) -> bool {
self.version == other.version && self.root == other.root && self.code == other.code
}
}
impl FormFile {
pub fn new(version: impl Into<String>, root: FormNode, code: impl Into<String>) -> Self {
Self {
version: version.into(),
root,
code: code.into(),
original: None,
}
}
pub fn catalog(&self) -> FormCatalog {
fn add(node: &FormNode, parent: Option<&str>, catalog: &mut FormCatalog) {
if let Some((id, _)) = catalog.find(&node.name) {
catalog.objects[id as usize].array = true;
} else {
let array = forms::property(node.class, "INDEX")
.and_then(|(property, _)| node.properties.get(&property))
.is_some();
catalog.add(&node.name, node.class, parent, array);
}
for child in &node.children {
add(child, Some(&node.name), catalog);
}
}
let mut catalog = FormCatalog::default();
add(&self.root, None, &mut catalog);
catalog
}
pub fn code_line(&self) -> u32 {
self.original.as_ref().map_or(1, |original| {
original.bytes[..original.bytes.len() - original.code.len()]
.bytes()
.filter(|b| *b == b'\n')
.count() as u32
+ 1
})
}
pub fn initial_values(
&self,
catalog: &FormCatalog,
) -> Result<Vec<FormInitial>, tb_runtime::errors::RuntimeError> {
fn collect(
node: &FormNode,
parent: Option<(u16, Option<i32>)>,
catalog: &FormCatalog,
form: &str,
out: &mut Vec<FormInitial>,
depth: usize,
) -> Result<(), tb_runtime::errors::RuntimeError> {
let depth = depth + usize::from(node.class == ObjectClass::Menu);
if depth > 6 {
return Err(tb_runtime::errors::RuntimeError(5));
}
let object = catalog
.objects
.iter()
.position(|o| {
o.name.eq_ignore_ascii_case(&node.name)
&& ((o.array && catalog.belongs_to(o, form))
|| o.parent == parent.map(|p| p.0))
&& o.class == node.class
})
.ok_or(tb_runtime::errors::RuntimeError(420))? as u16;
let index = forms::property(node.class, "INDEX")
.and_then(|(id, _)| node.properties.get(&id))
.and_then(|v| {
if let PropertyValue::Integer(n) = v {
Some(*n)
} else {
None
}
})
.unwrap_or(0);
if out.iter().any(|v| v.object == object && v.index == index) {
return Err(tb_runtime::errors::RuntimeError(5));
}
let mut properties = node.properties.clone();
if let Some((id, _)) = forms::property(node.class, "PARENT") {
properties.insert(id, PropertyValue::Object(parent));
}
out.push(FormInitial {
object,
index,
properties,
});
for child in &node.children {
collect(
child,
Some((object, (index != 0).then_some(index))),
catalog,
form,
out,
depth,
)?;
}
Ok(())
}
let mut values = Vec::new();
collect(&self.root, None, catalog, &self.root.name, &mut values, 0)?;
Ok(values)
}
pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> {
let catalog = FormCatalog {
objects: model
.objects
.iter()
.map(|o| o.description.clone())
.collect(),
};
for initial in self.initial_values(&catalog)? {
initial.apply(model)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FormInitial {
pub object: u16,
pub index: i32,
pub properties: BTreeMap<u16, PropertyValue>,
}
impl FormInitial {
pub fn apply(&self, model: &mut FormsModel) -> Result<(), tb_runtime::errors::RuntimeError> {
if self.index != 0 && !model.is_loaded_at(self.object, Some(self.index)) {
model.load_design_array(self.object, self.index)?;
}
for (property, value) in &self.properties {
model.set_initial_at(self.object, Some(self.index), *property, value.clone())?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormError {
pub file: String,
pub line: usize,
pub name: String,
pub message: String,
}
impl fmt::Display for FormError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{}:{}: {}: {}",
self.file, self.line, self.name, self.message
)
}
}
impl std::error::Error for FormError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinaryWarning {
pub offset: usize,
pub name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BinaryRead {
pub form: FormFile,
pub skipped: Vec<BinaryWarning>,
}
pub(crate) fn error(
file: &str,
line: usize,
name: impl Into<String>,
message: impl Into<String>,
) -> FormError {
FormError {
file: file.into(),
line,
name: name.into(),
message: message.into(),
}
}
pub fn read_text(file: &str, source: &str) -> Result<FormFile, FormError> {
let lines: Vec<&str> = source.split_inclusive('\n').collect();
let first = lines.first().copied().unwrap_or("");
let first = first.trim_end_matches(['\r', '\n']);
let Some(version) = first
.strip_prefix("VERSION ")
.or_else(|| first.strip_prefix("Version "))
else {
return Err(error(file, 1, "VERSION", "VERSION-Zeile fehlt"));
};
if version != "1.00" && version != "2.00" {
return Err(error(
file,
1,
version,
format!("unbekannte Version {version}"),
));
}
let mut at = 1;
let root = parse_node(file, &lines, &mut at)?;
if root.class != ObjectClass::Form {
return Err(error(
file,
2,
root.class.display_name(),
"Wurzel ist kein Form-Block",
));
}
if at < lines.len() && lines[at].trim().is_empty() {
at += 1;
}
let code = lines[at..].concat();
let mut result = FormFile::new(version, root, code);
result.original = Some(Original {
version: result.version.clone(),
root: result.root.clone(),
code: result.code.clone(),
bytes: source.into(),
});
Ok(result)
}
fn parse_node(file: &str, lines: &[&str], at: &mut usize) -> Result<FormNode, FormError> {
let begin_line = *at + 1;
let raw = lines
.get(*at)
.copied()
.ok_or_else(|| error(file, begin_line, "Begin", "Block erwartet"))?;
let text = raw.trim();
let mut words = text.split_whitespace();
if !words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("BEGIN"))
{
return Err(error(file, begin_line, text, "Begin-Block erwartet"));
}
let class_name = words.next().unwrap_or("");
let Some(class) = ObjectClass::parse(class_name) else {
return Err(error(file, begin_line, class_name, "unbekannte Klasse"));
};
let name = words.next().unwrap_or("");
if name.is_empty() || words.next().is_some() {
return Err(error(
file,
begin_line,
class_name,
"ungueltiger Begin-Block",
));
}
*at += 1;
let mut node = FormNode {
class,
name: name.into(),
properties: BTreeMap::new(),
children: Vec::new(),
};
while let Some(raw) = lines.get(*at).copied() {
let line_no = *at + 1;
let text = raw.trim();
if text.eq_ignore_ascii_case("END") {
*at += 1;
return Ok(node);
}
if text
.split_whitespace()
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("BEGIN"))
{
node.children.push(parse_node(file, lines, at)?);
continue;
}
let Some((property_name, raw_value)) = text.split_once('=') else {
return Err(error(
file,
line_no,
&node.name,
"Eigenschaft oder Begin/End erwartet",
));
};
let property_name = property_name.trim();
let Some((id, spec)) = forms::property(class, property_name) else {
return Err(error(
file,
line_no,
property_name,
format!("unbekannte Eigenschaft fuer {}", class.display_name()),
));
};
let value = parse_value(file, line_no, &node.name, spec, raw_value.trim())?;
node.properties.insert(id, value);
*at += 1;
}
Err(error(
file,
begin_line,
&node.name,
format!("offener Begin-Block {}", node.name),
))
}
fn parse_value(
file: &str,
line: usize,
object: &str,
spec: PropertySpec,
raw: &str,
) -> Result<PropertyValue, FormError> {
let bad = |message: String| error(file, line, format!("{}.{}", object, spec.name), message);
match spec.ty {
PropertyType::String => {
if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
return Err(bad("Zeichenkette in Anfuehrungszeichen erwartet".into()));
}
let mut value = String::new();
let mut chars = raw[1..raw.len() - 1].chars();
while let Some(ch) = chars.next() {
if ch == '"' && chars.next() != Some('"') {
return Err(bad("Anfuehrungszeichen muss verdoppelt werden".into()));
}
value.push(ch);
}
Ok(PropertyValue::String(value))
}
PropertyType::Integer => {
let number = raw
.strip_prefix("Char(")
.or_else(|| raw.strip_prefix("QBColor("))
.and_then(|value| value.strip_suffix(')'))
.unwrap_or(raw)
.parse::<i32>()
.map_err(|_| bad(format!("ungueltiger Wert {raw}")))?;
validate_range(file, line, object, spec, number)?;
Ok(PropertyValue::Integer(number))
}
PropertyType::Single => raw
.parse::<f32>()
.map(PropertyValue::Single)
.map_err(|_| bad(format!("ungueltiger Wert {raw}"))),
PropertyType::Boolean => match raw.to_ascii_uppercase().as_str() {
"TRUE" | "-1" | "1" => Ok(PropertyValue::Boolean(true)),
"FALSE" | "0" => Ok(PropertyValue::Boolean(false)),
_ => Err(bad(format!("ungueltiger boolescher Wert {raw}"))),
},
PropertyType::Object | PropertyType::IntegerArray => Err(bad(format!(
"Werttyp fuer {} ist im Dateiformat nicht schreibbar",
spec.name
))),
}
}
fn validate_range(
file: &str,
location: usize,
object: &str,
spec: PropertySpec,
number: i32,
) -> Result<(), FormError> {
if spec.min.is_some_and(|min| number < min) || spec.max.is_some_and(|max| number > max) {
return Err(error(
file,
location,
format!("{}.{}", object, spec.name),
format!("Wert {number} ausserhalb des Wertebereichs"),
));
}
Ok(())
}
pub fn write_text(form: &FormFile) -> String {
if let Some(original) = &form.original {
if original.version == form.version
&& original.root == form.root
&& original.code == form.code
{
return original.bytes.clone();
}
}
let mut out = format!("VERSION {}\n", form.version);
write_node(&form.root, 0, &mut out);
if !form.code.is_empty() {
out.push('\n');
out.push_str(&form.code);
}
out
}
pub fn read_binary(file: &str, bytes: &[u8]) -> Result<BinaryRead, FormError> {
if bytes.get(..4) != Some(&[0xfc, 0x08, 0x01, 0x00]) {
return Err(error(file, 0, "Byte 0x0000", "Kennung FC 08 01 00 fehlt"));
}
let object_len = read_u16(bytes, 0x1e)
.ok_or_else(|| error(file, 0x1e, "Objektbereich", "abgeschnittener Dateikopf"))?
as usize;
let object_end = 0x20usize
.checked_add(object_len)
.filter(|end| *end <= bytes.len())
.ok_or_else(|| error(file, 0x1e, "Objektbereich", "ungueltige Laenge"))?;
let table_at = read_u16(bytes, 0x1c)
.map(|offset| offset as usize + 0x16)
.filter(|start| *start >= object_end && *start < bytes.len())
.ok_or_else(|| error(file, 0x1c, "Objektkatalog", "ungueltiger Verweis"))?;
let symbols = read_symbol_table(bytes, table_at).map_err(|at| {
error(
file,
at,
format!("Byte 0x{at:04x}"),
"unerkannte Katalogstruktur",
)
})?;
if symbols[0].class != ObjectClass::Form {
return Err(error(
file,
table_at,
symbols[0].class.display_name(),
"Wurzel ist kein Formular",
));
}
let strings = read_string_table(bytes, object_end, table_at)
.ok_or_else(|| error(file, object_end, "Zeichenketten", "unerkannte Struktur"))?;
let object_bytes = &bytes[0x20..object_end];
let mut root = None;
let mut skipped = None;
let mut first_layout_error = None;
read_object_records(&symbols, object_bytes, &mut |records| {
let mut decoded = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, object_bytes))
.collect::<Vec<_>>();
if let Err(failure) = validate_decoded(file, records, &decoded) {
first_layout_error.get_or_insert(failure);
return false;
}
let warnings = match assign_strings(
file,
&symbols,
records,
object_bytes,
&strings,
&mut decoded,
) {
Ok(warnings) => warnings,
Err(failure) => {
first_layout_error.get_or_insert(failure);
return false;
}
};
root = build_tree(decoded);
if root.is_none() {
first_layout_error.get_or_insert_with(|| {
error(
file,
0x20,
"Containerverweise",
"unerkannte Containerstruktur",
)
});
}
if root.is_some() {
skipped = Some(warnings);
}
root.is_some()
})
.ok_or_else(|| {
first_layout_error.unwrap_or_else(|| {
error(
file,
0x20,
format!("{}.Datensatzstruktur", symbols[0].name),
"unerkannte Datensatzstruktur",
)
})
})?;
let catalog_end = symbols
.last()
.map(|symbol| symbol.offset + 4 + symbol.name.len())
.unwrap_or(table_at);
let code = crate::frm_pcode::decode(file, bytes, catalog_end)?;
Ok(BinaryRead {
form: FormFile::new("1.00", root.unwrap(), code),
skipped: skipped.unwrap(),
})
}
fn set_property(node: &mut FormNode, name: &str, value: PropertyValue) {
if let Some((id, _)) = forms::property(node.class, name) {
node.properties.insert(id, value);
}
}
fn validate_decoded(
file: &str,
records: &[BinaryRecord],
decoded: &[DecodedObject],
) -> Result<(), FormError> {
for (record, object) in records.iter().zip(decoded) {
let specs = forms::properties(object.node.class);
for (id, value) in &object.node.properties {
let Some(spec) = specs.get(*id as usize).copied() else {
continue;
};
if let PropertyValue::Integer(number) = value {
validate_range(file, 0x20 + record.start, &object.node.name, spec, *number)?;
}
}
}
Ok(())
}
pub(crate) fn read_u16(bytes: &[u8], at: usize) -> Option<u16> {
Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?]))
}
#[derive(Debug)]
struct BinarySymbol {
offset: usize,
class: ObjectClass,
name: String,
is_array: bool,
unsupported: bool,
}
fn read_symbol_table(bytes: &[u8], mut at: usize) -> Result<Vec<BinarySymbol>, usize> {
let mut symbols = Vec::new();
loop {
if at + 4 > bytes.len() {
return Err(at);
}
let reference = read_u16(bytes, at).ok_or(at)?;
let class_id = bytes[at + 2] & 0x7f;
let length = bytes[at + 3] as usize;
let mut class = ObjectClass::from_id(class_id).ok_or(at + 2)?;
if length == 0 || length > 40 || at + 4 + length > bytes.len() {
return Err(at + 3);
}
let name = &bytes[at + 4..at + 4 + length];
if !name[0].is_ascii_alphabetic()
|| !name
.iter()
.all(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
{
return Err(at + 4);
}
if symbols.is_empty() && class != ObjectClass::Form {
return Err(at + 2);
}
let name = String::from_utf8_lossy(name).into_owned();
if class == ObjectClass::Screen
&& matches!(name.to_ascii_uppercase().as_str(), "VSPIN" | "HSPIN")
{
class = ObjectClass::Spin;
}
symbols.push(BinarySymbol {
offset: at,
class,
name,
is_array: bytes[at + 2] & 0x80 != 0,
unsupported: class_id == ObjectClass::Screen.id() && class != ObjectClass::Spin,
});
at += 4 + length;
if reference == 0 {
return Ok(symbols);
}
}
}
#[derive(Debug)]
struct BinaryRecord {
symbol: usize,
start: usize,
end: usize,
class_flags: u8,
common_flags: u8,
}
#[derive(Debug)]
struct BinaryString {
pointer: u16,
value: String,
}
fn record_len(class: ObjectClass) -> usize {
match class {
ObjectClass::Form => 38,
ObjectClass::ComboBox => 39,
ObjectClass::DirListBox
| ObjectClass::DriveListBox
| ObjectClass::HScrollBar
| ObjectClass::ListBox
| ObjectClass::VScrollBar => 32,
ObjectClass::FileListBox => 36,
ObjectClass::PictureBox => 31,
ObjectClass::TextBox => 34,
ObjectClass::Menu | ObjectClass::Frame => 26,
ObjectClass::Screen | ObjectClass::Spin => 80,
_ => 28,
}
}
fn record_lengths(class: ObjectClass) -> &'static [usize] {
match class {
ObjectClass::Form => &[31, 33, 38],
ObjectClass::CommandButton => &[23, 28],
ObjectClass::CheckBox => &[28],
ObjectClass::ComboBox => &[33, 39],
ObjectClass::DirListBox | ObjectClass::DriveListBox => &[32],
ObjectClass::FileListBox => &[32, 36],
ObjectClass::ListBox => &[17, 28, 32],
ObjectClass::TextBox => &[28, 34, 39],
ObjectClass::Label => &[21, 26, 27, 28, 34, 36],
ObjectClass::Frame => &[26, 31],
ObjectClass::Menu => &[26, 28],
ObjectClass::OptionButton => &[21, 28],
ObjectClass::PictureBox => &[31],
ObjectClass::HScrollBar | ObjectClass::VScrollBar => &[32],
ObjectClass::Screen | ObjectClass::Spin => &[73, 80],
ObjectClass::Timer => &[21, 28],
}
}
fn read_object_records(
symbols: &[BinarySymbol],
bytes: &[u8],
validate: &mut impl FnMut(&[BinaryRecord]) -> bool,
) -> Option<Vec<BinaryRecord>> {
let mut pointer_start = bytes.len();
let mut first = usize::MAX;
while pointer_start >= 2 {
let value = read_u16(bytes, pointer_start - 2)? as usize;
if value == 0 || value >= first {
break;
}
first = value;
pointer_start -= 2;
}
let all_pointers = bytes[pointer_start..]
.chunks_exact(2)
.map(|value| u16::from_le_bytes([value[0], value[1]]) as usize)
.collect::<Vec<_>>();
if all_pointers
.iter()
.any(|value| *value == 0 || *value >= pointer_start + 3)
{
return None;
}
for dropped in 0..=all_pointers.len() {
let start = pointer_start + dropped * 2;
let data_end = start;
let pointers = &all_pointers[dropped..];
let mut records = Vec::new();
let mut seen = vec![0u8; symbols.len()];
let mut failed = HashSet::new();
let mut validate_with_pointers = |records: &[BinaryRecord]| {
pointers
.iter()
.all(|pointer| records.iter().any(|record| record.start + 3 == *pointer))
&& validate(records)
};
if walk_object_records(
symbols,
bytes,
data_end,
0,
0,
0,
0,
&mut records,
&mut seen,
&mut failed,
&mut validate_with_pointers,
) {
return Some(records);
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn walk_object_records(
symbols: &[BinarySymbol],
bytes: &[u8],
data_end: usize,
cursor: usize,
symbol: usize,
class_flags: u8,
common_flags: u8,
records: &mut Vec<BinaryRecord>,
seen: &mut [u8],
failed: &mut HashSet<(usize, usize, Vec<u8>)>,
validate: &mut impl FnMut(&[BinaryRecord]) -> bool,
) -> bool {
let state = (cursor, symbol, seen.to_vec());
if failed.contains(&state)
|| cursor + 6 > data_end
|| (seen[symbol] != 0 && !symbols[symbol].is_array)
{
return false;
}
let index = read_u16(bytes, cursor + 4);
if symbols[symbol].is_array
&& records
.iter()
.any(|record| record.symbol == symbol && read_u16(bytes, record.start + 4) == index)
{
return false;
}
seen[symbol] = seen[symbol].saturating_add(1);
let mut distances = record_lengths(symbols[symbol].class).to_vec();
distances.sort_by_key(|distance| distance.abs_diff(record_len(symbols[symbol].class)));
for distance in distances {
let next_start = cursor + distance;
let Some(header) = next_start.checked_sub(7) else {
continue;
};
if next_start >= data_end || header < cursor + 6 || header + 7 > data_end {
continue;
}
let next_symbol = bytes[header] as usize;
if next_symbol >= symbols.len()
|| bytes[header + 2] != 0
|| bytes[header + 1] & 0x7f
!= if symbols[next_symbol].class == ObjectClass::Spin {
ObjectClass::Screen.id()
} else {
symbols[next_symbol].class.id()
}
{
continue;
}
records.push(BinaryRecord {
symbol,
start: cursor,
end: header,
class_flags,
common_flags,
});
if walk_object_records(
symbols,
bytes,
data_end,
next_start,
next_symbol,
bytes[header + 3],
bytes[header + 4],
records,
seen,
failed,
validate,
) {
return true;
}
records.pop();
}
let terminal_len = data_end - cursor;
if !record_lengths(symbols[symbol].class).contains(&terminal_len)
&& !record_lengths(symbols[symbol].class).contains(&(terminal_len + 7))
{
seen[symbol] -= 1;
failed.insert(state);
return false;
}
records.push(BinaryRecord {
symbol,
start: cursor,
end: data_end,
class_flags,
common_flags,
});
if seen.iter().all(|count| *count != 0) && validate(records) {
return true;
}
records.pop();
seen[symbol] -= 1;
failed.insert(state);
false
}
#[derive(Debug)]
struct DecodedObject {
node: FormNode,
start: usize,
parent: u16,
unsupported: bool,
}
fn record_byte(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option<u8> {
(start + offset < end).then(|| bytes[start + offset])
}
fn record_u16(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option<u16> {
(start + offset + 2 <= end)
.then(|| read_u16(bytes, start + offset))
.flatten()
}
fn decode_object(symbol: &BinarySymbol, record: &BinaryRecord, bytes: &[u8]) -> DecodedObject {
let start = record.start;
let end = record.end;
let mut node = FormNode {
class: symbol.class,
name: symbol.name.clone(),
properties: BTreeMap::new(),
children: Vec::new(),
};
if symbol.class == ObjectClass::Form {
let class_flags = record_byte(bytes, start, end, 3).unwrap_or(0);
let common_flags = record_byte(bytes, start, end, 4).unwrap_or(0);
set_property(
&mut node,
"CONTROLBOX",
PropertyValue::Boolean(class_flags & 0x20 != 0),
);
set_property(
&mut node,
"AUTOREDRAW",
PropertyValue::Boolean(class_flags & 0x08 != 0),
);
set_property(
&mut node,
"FORMTYPE",
PropertyValue::Integer(i32::from(class_flags & 0x02 != 0)),
);
set_property(
&mut node,
"ENABLED",
PropertyValue::Boolean(common_flags & 0x01 != 0),
);
set_property(
&mut node,
"MAXBUTTON",
PropertyValue::Boolean(common_flags & 0x02 != 0),
);
set_property(
&mut node,
"MINBUTTON",
PropertyValue::Boolean(common_flags & 0x04 != 0),
);
set_property(
&mut node,
"VISIBLE",
PropertyValue::Boolean(common_flags & 0x80 != 0),
);
for (name, offset) in [
("TOP", 15),
("LEFT", 16),
("HEIGHT", 17),
("WIDTH", 18),
("CURRENTX", 19),
("CURRENTY", 20),
] {
if let Some(value) = record_byte(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i32));
}
}
if let Some(value) = record_byte(bytes, start, end, 21) {
set_property(&mut node, "BACKCOLOR", PropertyValue::Integer(value as i32));
}
if let Some(value) = record_byte(bytes, start, end, 22) {
set_property(&mut node, "FORECOLOR", PropertyValue::Integer(value as i32));
}
if let Some(value) = record_byte(bytes, start, end, 26) {
set_property(
&mut node,
"BORDERSTYLE",
PropertyValue::Integer(value as i32),
);
}
} else {
set_property(
&mut node,
"ENABLED",
PropertyValue::Boolean(record.common_flags & 0x01 != 0),
);
set_property(
&mut node,
"VISIBLE",
PropertyValue::Boolean(record.common_flags & 0x80 != 0),
);
set_property(
&mut node,
"TABSTOP",
PropertyValue::Boolean(record.common_flags & 0x40 != 0),
);
if symbol.class == ObjectClass::CommandButton {
set_property(
&mut node,
"CANCEL",
PropertyValue::Boolean(record.common_flags & 0x10 != 0),
);
set_property(
&mut node,
"DEFAULT",
PropertyValue::Boolean(record.class_flags & 0x04 != 0),
);
}
if symbol.class == ObjectClass::Menu {
set_property(
&mut node,
"SEPARATOR",
PropertyValue::Boolean(record.class_flags & 0x01 != 0),
);
set_property(
&mut node,
"CHECKED",
PropertyValue::Boolean(record.class_flags & 0x40 != 0),
);
}
for (name, offset) in [("TOP", 8), ("LEFT", 9), ("HEIGHT", 10), ("WIDTH", 11)] {
if let Some(value) = record_byte(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i32));
}
}
if let Some(value) = record_u16(bytes, start, end, 4).filter(|_| symbol.is_array) {
set_property(
&mut node,
"INDEX",
PropertyValue::Integer(value as i16 as i32),
);
}
if let Some(value) = record_byte(bytes, start, end, 13) {
set_property(&mut node, "TABINDEX", PropertyValue::Integer(value as i32));
}
if !matches!(
symbol.class,
ObjectClass::HScrollBar | ObjectClass::VScrollBar
) {
for (name, offset) in [("BACKCOLOR", 14), ("FORECOLOR", 15)] {
if let Some(value) = record_byte(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i32));
}
}
}
match symbol.class {
ObjectClass::ListBox => {
set_property(
&mut node,
"SORTED",
PropertyValue::Boolean(record.class_flags & 0x80 != 0),
);
}
ObjectClass::TextBox => {
set_property(
&mut node,
"MULTILINE",
PropertyValue::Boolean(record.common_flags & 0x08 != 0),
);
if let Some(value) = record_byte(bytes, start, end, 19) {
set_property(
&mut node,
"BORDERSTYLE",
PropertyValue::Integer(value as i32),
);
}
if let Some(value) = record_byte(bytes, start, end, 20) {
set_property(
&mut node,
"SCROLLBARS",
PropertyValue::Integer(value as i32),
);
}
}
ObjectClass::ComboBox => {
if let Some(value) = record_byte(bytes, start, end, 31) {
set_property(&mut node, "STYLE", PropertyValue::Integer(value as i32));
}
}
ObjectClass::Label => {
set_property(
&mut node,
"AUTOSIZE",
PropertyValue::Boolean(record.class_flags & 0x20 != 0),
);
for (name, offset) in [("BORDERSTYLE", 19), ("ALIGNMENT", 20)] {
if let Some(value) = record_byte(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i32));
}
}
}
ObjectClass::OptionButton | ObjectClass::CheckBox => {
if let Some(value) = record_u16(bytes, start, end, 19) {
set_property(
&mut node,
"VALUE",
PropertyValue::Integer(value as i16 as i32),
);
}
}
ObjectClass::PictureBox => {
set_property(
&mut node,
"AUTOREDRAW",
PropertyValue::Boolean(record.class_flags & 0x08 != 0),
);
if let Some(value) = record_byte(bytes, start, end, 16) {
set_property(
&mut node,
"BORDERSTYLE",
PropertyValue::Integer(value as i32),
);
}
for (name, offset) in [("CURRENTX", 17), ("CURRENTY", 19)] {
if let Some(value) = record_u16(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32));
}
}
}
ObjectClass::HScrollBar | ObjectClass::VScrollBar => {
if let Some(value) = record_byte(bytes, start, end, 14) {
set_property(&mut node, "ATTACHED", PropertyValue::Boolean(value == 0));
}
for (name, offset) in [
("SMALLCHANGE", 17),
("LARGECHANGE", 19),
("MAX", 21),
("MIN", 23),
] {
if let Some(value) = record_u16(bytes, start, end, offset) {
set_property(&mut node, name, PropertyValue::Integer(value as i16 as i32));
}
}
if let Some(value) = record_u16(bytes, start, end, 23) {
set_property(
&mut node,
"VALUE",
PropertyValue::Integer(value as i16 as i32),
);
}
}
ObjectClass::Timer => {
if let Some(value) = record_u16(bytes, start, end, 17) {
set_property(&mut node, "INTERVAL", PropertyValue::Integer(value as i32));
}
}
_ => {}
}
}
DecodedObject {
node,
start,
parent: record_u16(bytes, start, end, 0).unwrap(),
unsupported: symbol.unsupported,
}
}
fn build_tree(mut objects: Vec<DecodedObject>) -> Option<FormNode> {
if objects.first()?.node.class != ObjectClass::Form {
return None;
}
for child in (1..objects.len()).rev() {
if objects[child].unsupported {
objects.remove(child);
continue;
}
let parent = if objects[child].parent == 10 {
0
} else {
objects[..child]
.iter()
.position(|object| object.start + 3 == objects[child].parent as usize)?
};
let node = objects.remove(child).node;
objects[parent].node.children.insert(0, node);
}
Some(objects.remove(0).node)
}
fn assign_strings(
file: &str,
symbols: &[BinarySymbol],
records: &[BinaryRecord],
bytes: &[u8],
strings: &[BinaryString],
decoded: &mut [DecodedObject],
) -> Result<Vec<BinaryWarning>, FormError> {
let mut warnings = records
.iter()
.filter(|record| symbols[record.symbol].unsupported)
.map(|record| BinaryWarning {
offset: 0x20 + record.start,
name: format!("{}.CustomControl", symbols[record.symbol].name),
})
.collect::<Vec<_>>();
warnings.extend(records.iter().filter_map(|record| {
let symbol = &symbols[record.symbol];
(symbol.class == ObjectClass::Menu
&& record.end - record.start >= 21
&& record_u16(bytes, record.start, record.end, 19).is_some_and(|value| value != 0))
.then(|| BinaryWarning {
offset: 0x20 + record.start + 19,
name: format!("{}.Shortcut", symbol.name),
})
}));
let by_pointer = strings
.iter()
.enumerate()
.map(|(index, string)| (string.pointer, (index, &string.value)))
.collect::<BTreeMap<_, _>>();
let mut used = HashSet::new();
for (offset, name) in [(9, "TAG"), (24, "CAPTION")] {
if let Some(pointer) = record_u16(bytes, records[0].start, records[0].end, offset)
.filter(|pointer| *pointer != 0)
{
assign_binary_string(
file,
symbols,
records,
decoded,
&by_pointer,
&mut used,
0,
name,
pointer,
)?;
}
}
for (index, record) in records.iter().enumerate().skip(1) {
let class = symbols[record.symbol].class;
if let Some(pointer) =
record_u16(bytes, record.start, record.end, 2).filter(|pointer| *pointer != 0)
{
assign_binary_string(
file,
symbols,
records,
decoded,
&by_pointer,
&mut used,
index,
"TAG",
pointer,
)?;
}
let (offset, name) = match class {
ObjectClass::TextBox => (21, "TEXT"),
ObjectClass::ComboBox => (29, "TEXT"),
ObjectClass::FileListBox => (27, "PATTERN"),
ObjectClass::Label if record.end - record.start >= 34 => (21, "CAPTION"),
ObjectClass::CommandButton
| ObjectClass::CheckBox
| ObjectClass::OptionButton
| ObjectClass::Frame
| ObjectClass::Label
| ObjectClass::Menu => (17, "CAPTION"),
_ => continue,
};
if let Some(pointer) =
record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0)
{
assign_binary_string(
file,
symbols,
records,
decoded,
&by_pointer,
&mut used,
index,
name,
pointer,
)?;
}
}
for record in records.iter().filter(|record| {
symbols[record.symbol].unsupported || symbols[record.symbol].class == ObjectClass::Spin
}) {
for offset in [17, 28, 37, 39, 53, 55, 64, 69] {
let Some(pointer) =
record_u16(bytes, record.start, record.end, offset).filter(|pointer| *pointer != 0)
else {
continue;
};
let (string_index, _) = by_pointer.get(&pointer).ok_or_else(|| {
error(
file,
0x20 + record.start + offset,
format!("{}.CustomControl", symbols[record.symbol].name),
format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"),
)
})?;
used.insert(*string_index);
}
}
let remaining = strings.len() - used.len();
if remaining != 0 {
return Err(error(
file,
0x20,
"Zeichenketten",
format!("{remaining} nicht zugeordnete Eintraege"),
));
}
Ok(warnings)
}
#[allow(clippy::too_many_arguments)]
fn assign_binary_string(
file: &str,
symbols: &[BinarySymbol],
records: &[BinaryRecord],
decoded: &mut [DecodedObject],
by_pointer: &BTreeMap<u16, (usize, &String)>,
used: &mut HashSet<usize>,
index: usize,
name: &'static str,
pointer: u16,
) -> Result<(), FormError> {
let (string_index, value) = by_pointer.get(&pointer).ok_or_else(|| {
error(
file,
0x20 + records[index].start,
format!("{}.{}", symbols[records[index].symbol].name, name),
format!("ungueltiger Zeichenkettenverweis 0x{pointer:04x}"),
)
})?;
used.insert(*string_index);
set_property(
&mut decoded[index].node,
name,
PropertyValue::String((*value).clone()),
);
Ok(())
}
fn read_string_table(bytes: &[u8], mut at: usize, end: usize) -> Option<Vec<BinaryString>> {
let mut strings = Vec::new();
while at < end {
let pointer = u16::try_from(at.checked_sub(0x16)?).ok()?;
let length = read_u16(bytes, at)? as usize;
at += 2;
let value = bytes.get(at..at.checked_add(length)?)?;
if at + length > end {
return None;
}
strings.push(BinaryString {
pointer,
value: decode_cp437(value),
});
at += length;
}
(at == end).then_some(strings)
}
pub(crate) fn decode_cp437(bytes: &[u8]) -> String {
const LOW: [char; 31] = [
'☺', '☻', '♥', '♦', '♣', '♠', '•', '◘', '○', '◙', '♂', '♀', '♪', '♫', '☼', '►', '◄', '↕',
'‼', '¶', '§', '▬', '↨', '↑', '↓', '→', '←', '∟', '↔', '▲', '▼',
];
const HIGH: [char; 128] = [
'Ç', 'ü', 'é', 'â', 'ä', 'à', 'å', 'ç', 'ê', 'ë', 'è', 'ï', 'î', 'ì', 'Ä', 'Å', 'É', 'æ',
'Æ', 'ô', 'ö', 'ò', 'û', 'ù', 'ÿ', 'Ö', 'Ü', '¢', '£', '¥', '₧', 'ƒ', 'á', 'í', 'ó', 'ú',
'ñ', 'Ñ', 'ª', 'º', '¿', '⌐', '¬', '½', '¼', '¡', '«', '»', '░', '▒', '▓', '│', '┤', '╡',
'╢', '╖', '╕', '╣', '║', '╗', '╝', '╜', '╛', '┐', '└', '┴', '┬', '├', '─', '┼', '╞', '╟',
'╚', '╔', '╩', '╦', '╠', '═', '╬', '╧', '╨', '╤', '╥', '╙', '╘', '╒', '╓', '╫', '╪', '┘',
'┌', '█', '▄', '▌', '▐', '▀', 'α', 'ß', 'Γ', 'π', 'Σ', 'σ', 'µ', 'τ', 'Φ', 'Θ', 'Ω', 'δ',
'∞', 'φ', 'ε', '∩', '≡', '±', '≥', '≤', '⌠', '⌡', '÷', '≈', '°', '∙', '·', '√', 'ⁿ', '²',
'■', '\u{a0}',
];
bytes
.iter()
.map(|byte| match *byte {
0 => '\0',
b'\t' => '\t',
1..=31 => LOW[*byte as usize - 1],
32..=126 => *byte as char,
127 => '⌂',
_ => HIGH[*byte as usize - 128],
})
.collect()
}
fn write_node(node: &FormNode, depth: usize, out: &mut String) {
let indent = " ".repeat(depth);
out.push_str(&format!(
"{indent}Begin {} {}\n",
node.class.display_name(),
node.name
));
let mut properties: Vec<_> = forms::properties(node.class)
.into_iter()
.enumerate()
.collect();
properties.sort_by_key(|(_, spec)| spec.name);
for (id, spec) in properties {
let Some(value) = node.properties.get(&(id as u16)) else {
continue;
};
if spec.name != "INDEX" && *value == default_value(spec) {
continue;
}
out.push_str(&" ".repeat(depth + 1));
out.push_str(&format!(
"{:<16} = {}\n",
property_name(spec.name),
format_value(value)
));
}
for child in &node.children {
write_node(child, depth + 1, out);
}
out.push_str(&format!("{indent}End\n"));
}
fn default_value(spec: PropertySpec) -> PropertyValue {
match spec.default {
PropertyDefault::Integer(value) if spec.ty == PropertyType::IntegerArray => {
PropertyValue::IntegerArray(vec![value; 18])
}
PropertyDefault::Integer(value) => PropertyValue::Integer(value),
PropertyDefault::Single(value) => PropertyValue::Single(value),
PropertyDefault::String(value) => PropertyValue::String(value.into()),
PropertyDefault::Boolean(value) => PropertyValue::Boolean(value),
PropertyDefault::Empty if spec.ty == PropertyType::Object => PropertyValue::Object(None),
PropertyDefault::Empty if spec.ty == PropertyType::String => {
PropertyValue::String(String::new())
}
PropertyDefault::Empty if spec.ty == PropertyType::Boolean => PropertyValue::Boolean(false),
PropertyDefault::Empty => PropertyValue::Integer(0),
}
}
fn format_value(value: &PropertyValue) -> String {
match value {
PropertyValue::Integer(value) => value.to_string(),
PropertyValue::Single(value) => value.to_string(),
PropertyValue::String(value) => format!("\"{}\"", value.replace('"', "\"\"")),
PropertyValue::Boolean(value) => if *value { "-1" } else { "0" }.into(),
PropertyValue::Object(_) | PropertyValue::IntegerArray(_) => "0".into(),
}
}
fn property_name(name: &'static str) -> &'static str {
match name {
"AUTOREDRAW" => "AutoRedraw",
"BACKCOLOR" => "BackColor",
"BORDERSTYLE" => "BorderStyle",
"CONTROLBOX" => "ControlBox",
"CURRENTX" => "CurrentX",
"CURRENTY" => "CurrentY",
"FORMNAME" => "FormName",
"FORMTYPE" => "FormType",
"FORECOLOR" => "ForeColor",
"LARGECHANGE" => "LargeChange",
"LISTCOUNT" => "ListCount",
"LISTINDEX" => "ListIndex",
"MAXBUTTON" => "MaxButton",
"MINBUTTON" => "MinButton",
"MOUSEPOINTER" => "MousePointer",
"MULTILINE" => "MultiLine",
"READONLY" => "ReadOnly",
"SCALEHEIGHT" => "ScaleHeight",
"SCALEWIDTH" => "ScaleWidth",
"SCROLLBARS" => "ScrollBars",
"SELLENGTH" => "SelLength",
"SELSTART" => "SelStart",
"SELTEXT" => "SelText",
"SMALLCHANGE" => "SmallChange",
"TABINDEX" => "TabIndex",
"TABSTOP" => "TabStop",
"WINDOWSTATE" => "WindowState",
_ => match name {
"ALIGNMENT" => "Alignment",
"ARCHIVE" => "Archive",
"ATTACHED" => "Attached",
"AUTOSIZE" => "AutoSize",
"CANCEL" => "Cancel",
"CAPTION" => "Caption",
"CHECKED" => "Checked",
"CTLNAME" => "CtlName",
"DEFAULT" => "Default",
"DRAGMODE" => "DragMode",
"DRIVE" => "Drive",
"ENABLED" => "Enabled",
"FILENAME" => "FileName",
"HEIGHT" => "Height",
"HIDDEN" => "Hidden",
"INDEX" => "Index",
"INTERVAL" => "Interval",
"LEFT" => "Left",
"LIST" => "List",
"MAX" => "Max",
"MIN" => "Min",
"NORMAL" => "Normal",
"PARENT" => "Parent",
"PATH" => "Path",
"PATTERN" => "Pattern",
"SEPARATOR" => "Separator",
"SORTED" => "Sorted",
"STYLE" => "Style",
"SYSTEM" => "System",
"TAG" => "Tag",
"TEXT" => "Text",
"TOP" => "Top",
"VALUE" => "Value",
"VISIBLE" => "Visible",
"WIDTH" => "Width",
"ACTIVEFORM" => "ActiveForm",
"ACTIVECONTROL" => "ActiveControl",
"CONTROLPANEL" => "ControlPanel",
_ => name,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
const EXAMPLE: &str = "VERSION 1.00\nBegin Form Form1\n Caption = \"Beispiel\"\n Height = 15\n Begin CommandButton cmdOK\n Caption = \"&OK\"\n End\nEnd\n\nSUB cmdOK_Click ()\n UNLOAD Form1\nEND SUB\n";
#[test]
fn explicit_default_is_preserved_until_canonical_write() {
let original = "VERSION 1.00\r\nBegin Form Form1\r\n Begin CommandButton Ok\r\n Enabled = -1\r\n End\r\nEnd\r\n";
let mut form = read_text("default.frm", original).unwrap();
assert_eq!(write_text(&form), original);
form.root.name = "Changed".into();
let canonical = write_text(&form);
assert_eq!(
canonical,
"VERSION 1.00\nBegin Form Changed\n Begin CommandButton Ok\n End\nEnd\n"
);
assert_eq!(
write_text(&read_text("canonical.frm", &canonical).unwrap()),
canonical
);
}
#[test]
fn canonical_index_zero_remains_an_array_and_array_parents_are_preserved() {
let mut form=read_text("array.frm", "VERSION 1.00\nBegin Form Form1\n Begin Frame A\n Begin Label Item\n Index = 0\n End\n End\n Begin Frame B\n Begin Label Item\n Index = 1\n End\n End\nEnd\n").unwrap();
form.code = "END\n".into();
let text = write_text(&form);
assert!(text.contains("Index = 0"));
let roundtrip = read_text("roundtrip.frm", &text).unwrap();
let catalog = roundtrip.catalog();
let item = catalog.find("Item").unwrap().0;
assert!(catalog.objects[item as usize].array);
let a = catalog.find("A").unwrap().0;
let b = catalog.find("B").unwrap().0;
let mut model = FormsModel::new(catalog.objects, 80, 25);
roundtrip.apply(&mut model).unwrap();
let parent = forms::property(ObjectClass::Label, "PARENT").unwrap().0;
assert_eq!(
model.get_at(item, Some(0), parent).unwrap(),
PropertyValue::Object(Some((a, None)))
);
assert_eq!(
model.get_at(item, Some(1), parent).unwrap(),
PropertyValue::Object(Some((b, None)))
);
form.root.children.pop();
let single = read_text("single.frm", &write_text(&form)).unwrap();
assert!(
single.catalog().objects[item as usize].array,
"Einzelelement mit Index 0 bleibt Array"
);
}
#[test]
fn reads_nested_form_and_preserves_source_exactly() {
let form = read_text("test.frm", EXAMPLE).unwrap();
assert_eq!(form.root.children[0].class, ObjectClass::CommandButton);
assert_eq!(form.code, "SUB cmdOK_Click ()\n UNLOAD Form1\nEND SUB\n");
assert_eq!(write_text(&form), EXAMPLE);
}
#[test]
fn text_form_control_arrays_share_one_catalog_object_and_keep_indices() {
let form = read_text(
"array.frm",
"VERSION 1.00\nBegin Form Form1\n Begin CommandButton Command1\n Index = 0\n Caption = \"null\"\n End\n Begin CommandButton Command1\n Index = 1\n Caption = \"eins\"\n End\nEnd\n",
)
.unwrap();
let catalog = form.catalog();
assert_eq!(catalog.objects.len(), 2);
assert!(catalog.objects[1].array);
let mut model = FormsModel::new(catalog.objects, 80, 25);
form.apply(&mut model).unwrap();
let caption = forms::property(ObjectClass::CommandButton, "CAPTION")
.unwrap()
.0;
assert_eq!(
model.get_at(1, Some(0), caption).unwrap(),
PropertyValue::String("null".into())
);
assert_eq!(
model.get_at(1, Some(1), caption).unwrap(),
PropertyValue::String("eins".into())
);
model.show(0, false).unwrap();
model.events.clear();
model.object_method(1, "SETFOCUS", vec![]).unwrap();
assert_eq!(model.next_event().unwrap().array_index, Some(0));
}
#[test]
fn rejects_version_class_property_value_and_open_block_with_location() {
let cases = [
("VERSION 3.00\n", "3.00", 1),
("VERSION 1.00\nBegin Knopf x\nEnd\n", "Knopf", 2),
(
"VERSION 1.00\nBegin CommandButton x\n Farbe = 3\nEnd\n",
"Farbe",
3,
),
(
"VERSION 1.00\nBegin CommandButton x\n Height = 0\nEnd\n",
"x.HEIGHT",
3,
),
(
"VERSION 1.00\nBegin Form x\n Caption = \"a\"b\"\nEnd\n",
"x.CAPTION",
3,
),
("VERSION 1.00\nBegin Form offen\n", "offen", 2),
];
for (source, name, line) in cases {
let failure = read_text("kaputt.frm", source).unwrap_err();
assert_eq!(failure.file, "kaputt.frm");
assert_eq!(failure.line, line);
assert!(failure.to_string().contains(name));
}
}
#[test]
fn writes_documented_d1_and_roundtrips_description() {
let mut root = FormNode {
class: ObjectClass::Form,
name: "Form1".into(),
properties: BTreeMap::new(),
children: vec![FormNode {
class: ObjectClass::CommandButton,
name: "cmdOK".into(),
properties: BTreeMap::new(),
children: Vec::new(),
}],
};
root.properties.insert(
forms::property(ObjectClass::Form, "Caption").unwrap().0,
PropertyValue::String("Beispiel".into()),
);
root.properties.insert(
forms::property(ObjectClass::Form, "Height").unwrap().0,
PropertyValue::Integer(15),
);
root.children[0].properties.insert(
forms::property(ObjectClass::CommandButton, "Caption")
.unwrap()
.0,
PropertyValue::String("&OK".into()),
);
let form = FormFile::new("1.00", root, "");
let expected = "VERSION 1.00\nBegin Form Form1\n Caption = \"Beispiel\"\n Height = 15\n Begin CommandButton cmdOK\n Caption = \"&OK\"\n End\nEnd\n";
let text = write_text(&form);
assert_eq!(text, expected);
assert_eq!(read_text("test.frm", &text).unwrap(), form);
}
#[test]
fn writes_no_properties_for_default_only_control() {
let form = FormFile::new(
"1.00",
FormNode {
class: ObjectClass::Form,
name: "Form1".into(),
properties: BTreeMap::new(),
children: vec![FormNode {
class: ObjectClass::CommandButton,
name: "Command1".into(),
properties: BTreeMap::new(),
children: Vec::new(),
}],
},
"",
);
assert_eq!(
write_text(&form),
"VERSION 1.00\nBegin Form Form1\n Begin CommandButton Command1\n End\nEnd\n"
);
}
#[test]
fn writes_read_only_boolean_only_when_it_differs_from_false() {
let property = forms::property(ObjectClass::VScrollBar, "ATTACHED")
.unwrap()
.0;
let mut root = FormNode {
class: ObjectClass::VScrollBar,
name: "VScroll1".into(),
properties: BTreeMap::from([(property, PropertyValue::Boolean(false))]),
children: Vec::new(),
};
assert!(!write_text(&FormFile::new("1.00", root.clone(), "")).contains("Attached"));
root.properties
.insert(property, PropertyValue::Boolean(true));
assert!(write_text(&FormFile::new("1.00", root, "")).contains("Attached = -1"));
}
#[test]
fn reads_real_vbdos_form_with_complete_documented_description() {
let hex = include_str!("../tests/data/new.frm.hex");
let bytes: Vec<u8> = hex
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
let read = read_binary("new.frm", &bytes).unwrap();
assert!(read.skipped.is_empty());
assert_eq!(
write_text(&read.form),
"VERSION 1.00\nBegin Form New\n Caption = \"New\"\n FormType = 1\n Height = 17\n Left = 15\n MaxButton = 0\n Top = 3\n Visible = -1\n Width = 63\n Begin CommandButton Command1\n Caption = \"Command1\"\n Height = 3\n Left = 8\n Top = 11\n Width = 12\n End\nEnd\n"
);
}
#[test]
fn binary_reader_rejects_magic_and_unknown_structure_with_offset() {
assert!(read_binary("fremd.frm", b"kein formular")
.unwrap_err()
.to_string()
.contains("0x0000"));
let mut bytes = vec![0xfc, 0x08, 0x01, 0x00];
bytes.resize(64, 0);
assert!(read_binary("kaputt.frm", &bytes)
.unwrap_err()
.to_string()
.contains("Objektkatalog"));
let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
bytes[0x1c..0x1e].copy_from_slice(&u16::MAX.to_le_bytes());
let failure = read_binary("zeiger.frm", &bytes).unwrap_err();
assert_eq!(failure.line, 0x1c);
assert!(failure.to_string().contains("Objektkatalog"));
let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
bytes[0x75] = 0x7f;
let failure = read_binary("mitten.frm", &bytes).unwrap_err();
assert_eq!(failure.line, 0x75);
assert!(failure.to_string().contains("Katalogstruktur"));
let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
bytes[0x75] = ObjectClass::Screen.id();
bytes[0x40] = ObjectClass::Screen.id();
let failure = read_binary("klassenkonflikt.frm", &bytes).unwrap_err();
assert_eq!(failure.line, 0x20);
let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
bytes[0x46..0x48].copy_from_slice(&999u16.to_le_bytes());
let failure = read_binary("struktur.frm", &bytes).unwrap_err();
assert_eq!(failure.line, 0x20);
assert_eq!(failure.name, "Containerverweise");
}
#[test]
fn binary_reader_rejects_out_of_range_property_with_offset() {
let mut bytes: Vec<u8> = include_str!("../tests/data/new.frm.hex")
.split_whitespace()
.map(|byte| u8::from_str_radix(byte, 16).unwrap())
.collect();
bytes[0x31] = 0;
let failure = read_binary("bereich.frm", &bytes).unwrap_err();
assert_eq!(failure.line, 0x20);
assert_eq!(failure.name, "New.HEIGHT");
}
#[test]
fn spin_symbol_wird_als_bedienbares_control_erkannt() {
let mut bytes = Vec::new();
bytes.extend_from_slice(&[1, 0, ObjectClass::Form.id(), 5]);
bytes.extend_from_slice(b"Form1");
bytes.extend_from_slice(&[0, 0, ObjectClass::Screen.id(), 5]);
bytes.extend_from_slice(b"VSpin");
let symbols = read_symbol_table(&bytes, 0).unwrap();
assert_eq!(symbols[1].class, ObjectClass::Spin);
assert!(!symbols[1].unsupported);
}
#[test]
fn unsupported_custom_control_is_named_and_not_emitted_as_screen() {
let symbols = vec![
BinarySymbol {
offset: 0,
class: ObjectClass::Form,
name: "Form1".into(),
is_array: false,
unsupported: false,
},
BinarySymbol {
offset: 0,
class: ObjectClass::Screen,
name: "Custom1".into(),
is_array: false,
unsupported: true,
},
];
let records = vec![
BinaryRecord {
symbol: 0,
start: 0,
end: 31,
class_flags: 0,
common_flags: 0,
},
BinaryRecord {
symbol: 1,
start: 38,
end: 111,
class_flags: 0,
common_flags: 0x81,
},
];
let mut bytes = vec![0; 111];
bytes[24..26].copy_from_slice(&100u16.to_le_bytes());
bytes[38..40].copy_from_slice(&10u16.to_le_bytes());
for (offset, pointer) in [17, 28, 37, 39, 53, 55, 64, 69].into_iter().zip(101u16..) {
bytes[38 + offset..40 + offset].copy_from_slice(&pointer.to_le_bytes());
}
let strings = (100..=108)
.map(|pointer| BinaryString {
pointer,
value: if pointer == 100 {
"Form1".into()
} else {
format!("Custom{pointer}")
},
})
.collect::<Vec<_>>();
let mut decoded = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, &bytes))
.collect::<Vec<_>>();
let warnings = assign_strings(
"custom.frm",
&symbols,
&records,
&bytes,
&strings,
&mut decoded,
)
.unwrap();
let root = build_tree(decoded).unwrap();
assert_eq!(warnings[0].offset, 0x46);
assert_eq!(warnings[0].name, "Custom1.CustomControl");
assert!(root.children.is_empty());
let mut strings_with_orphan = (100..=108)
.map(|pointer| BinaryString {
pointer,
value: format!("String{pointer}"),
})
.collect::<Vec<_>>();
strings_with_orphan.push(BinaryString {
pointer: 109,
value: "Orphan".into(),
});
let mut decoded = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, &bytes))
.collect::<Vec<_>>();
assert!(assign_strings(
"custom.frm",
&symbols,
&records,
&bytes,
&strings_with_orphan,
&mut decoded,
)
.unwrap_err()
.to_string()
.contains("nicht zugeordnete Eintraege"));
}
#[test]
fn unsupported_binary_property_is_named_and_unclaimed_strings_still_fail() {
let symbols = vec![
BinarySymbol {
offset: 0,
class: ObjectClass::Form,
name: "Form1".into(),
is_array: false,
unsupported: false,
},
BinarySymbol {
offset: 0,
class: ObjectClass::Menu,
name: "mnuOpen".into(),
is_array: false,
unsupported: false,
},
];
let records = vec![
BinaryRecord {
symbol: 0,
start: 0,
end: 31,
class_flags: 0,
common_flags: 0,
},
BinaryRecord {
symbol: 1,
start: 38,
end: 59,
class_flags: 0,
common_flags: 0x81,
},
];
let mut bytes = vec![0; 59];
bytes[57..59].copy_from_slice(&0x1234u16.to_le_bytes());
let mut decoded = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, &bytes))
.collect::<Vec<_>>();
let warnings =
assign_strings("menu.frm", &symbols, &records, &bytes, &[], &mut decoded).unwrap();
assert_eq!(warnings[0].offset, 0x59);
assert_eq!(warnings[0].name, "mnuOpen.Shortcut");
let strings = vec![BinaryString {
pointer: 100,
value: "orphan".into(),
}];
assert!(assign_strings(
"menu.frm",
&symbols,
&records,
&bytes,
&strings,
&mut decoded,
)
.unwrap_err()
.to_string()
.contains("nicht zugeordnete Eintraege"));
}
#[test]
fn binary_strings_and_header_flags_map_to_the_named_properties() {
let symbols = [
(ObjectClass::Form, "Form1"),
(ObjectClass::TextBox, "txtSearch"),
(ObjectClass::ListBox, "lstFound"),
(ObjectClass::CommandButton, "cmdSearch"),
]
.into_iter()
.map(|(class, name)| BinarySymbol {
offset: 0,
class,
name: name.into(),
is_array: false,
unsupported: false,
})
.collect::<Vec<_>>();
let records = vec![
BinaryRecord {
symbol: 0,
start: 0,
end: 31,
class_flags: 0,
common_flags: 0,
},
BinaryRecord {
symbol: 1,
start: 38,
end: 63,
class_flags: 0,
common_flags: 0xc1,
},
BinaryRecord {
symbol: 2,
start: 70,
end: 93,
class_flags: 0,
common_flags: 0x41,
},
BinaryRecord {
symbol: 3,
start: 100,
end: 123,
class_flags: 0x04,
common_flags: 0xd1,
},
];
let mut bytes = vec![0; 123];
bytes[3] = 0x28;
bytes[4] = 0x81;
bytes[19] = 2;
bytes[20] = 3;
bytes[22] = 7;
bytes[24..26].copy_from_slice(&100u16.to_le_bytes());
for start in [38, 70, 100] {
bytes[start..start + 2].copy_from_slice(&10u16.to_le_bytes());
}
bytes[40..42].copy_from_slice(&101u16.to_le_bytes());
bytes[59..61].copy_from_slice(&102u16.to_le_bytes());
bytes[72..74].copy_from_slice(&103u16.to_le_bytes());
bytes[102..104].copy_from_slice(&104u16.to_le_bytes());
bytes[117..119].copy_from_slice(&105u16.to_le_bytes());
let strings = [
(100, "Search"),
(101, ""),
(102, "*.*"),
(103, ""),
(104, ""),
(105, "&Search"),
]
.into_iter()
.map(|(pointer, value)| BinaryString {
pointer,
value: value.into(),
})
.collect::<Vec<_>>();
let mut decoded = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, &bytes))
.collect::<Vec<_>>();
assert!(assign_strings(
"search.frm",
&symbols,
&records,
&bytes,
&strings,
&mut decoded,
)
.unwrap()
.is_empty());
let root = build_tree(decoded).unwrap();
let value = |node: &FormNode, name| {
node.properties
.get(&forms::property(node.class, name).unwrap().0)
.cloned()
};
assert_eq!(
value(&root, "CAPTION"),
Some(PropertyValue::String("Search".into()))
);
assert_eq!(
value(&root, "AUTOREDRAW"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(value(&root, "FORECOLOR"), Some(PropertyValue::Integer(7)));
assert_eq!(value(&root, "CURRENTX"), Some(PropertyValue::Integer(2)));
assert_eq!(value(&root, "CURRENTY"), Some(PropertyValue::Integer(3)));
assert_eq!(
value(&root.children[0], "TEXT"),
Some(PropertyValue::String("*.*".into()))
);
assert_eq!(
value(&root.children[1], "VISIBLE"),
Some(PropertyValue::Boolean(false))
);
assert_eq!(
value(&root.children[2], "CAPTION"),
Some(PropertyValue::String("&Search".into()))
);
assert_eq!(
value(&root.children[2], "DEFAULT"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(
value(&root.children[2], "CANCEL"),
Some(PropertyValue::Boolean(true))
);
}
#[test]
fn binary_class_specific_properties_match_the_reference_exports() {
let decode = |class, class_flags, bytes: Vec<u8>| {
decode_object(
&BinarySymbol {
offset: 0,
class,
name: "Control1".into(),
is_array: false,
unsupported: false,
},
&BinaryRecord {
symbol: 0,
start: 0,
end: bytes.len(),
class_flags,
common_flags: 0xc1,
},
&bytes,
)
.node
};
let value = |node: &FormNode, name| {
node.properties
.get(&forms::property(node.class, name).unwrap().0)
.cloned()
};
let decode_form = |class_flags| {
let mut bytes = vec![0; 31];
bytes[3] = class_flags;
decode_object(
&BinarySymbol {
offset: 0,
class: ObjectClass::Form,
name: "Form1".into(),
is_array: false,
unsupported: false,
},
&BinaryRecord {
symbol: 0,
start: 0,
end: bytes.len(),
class_flags: 0,
common_flags: 0,
},
&bytes,
)
.node
};
let auto_redraw = decode_form(0x08);
assert_eq!(
value(&auto_redraw, "AUTOREDRAW"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(
value(&auto_redraw, "FORMTYPE"),
Some(PropertyValue::Integer(0))
);
let mdi = decode_form(0x02);
assert_eq!(
value(&mdi, "AUTOREDRAW"),
Some(PropertyValue::Boolean(false))
);
assert_eq!(value(&mdi, "FORMTYPE"), Some(PropertyValue::Integer(1)));
let mut label = vec![0; 21];
label[19] = 1;
label[20] = 2;
let label = decode(ObjectClass::Label, 0, label);
assert_eq!(
value(&label, "BORDERSTYLE"),
Some(PropertyValue::Integer(1))
);
assert_eq!(value(&label, "ALIGNMENT"), Some(PropertyValue::Integer(2)));
let mut picture = vec![0; 24];
picture[14] = 7;
picture[17..19].copy_from_slice(&2i16.to_le_bytes());
picture[19..21].copy_from_slice(&(-3i16).to_le_bytes());
let picture = decode(ObjectClass::PictureBox, 0x08, picture);
assert_eq!(
value(&picture, "AUTOREDRAW"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(
value(&picture, "BORDERSTYLE"),
Some(PropertyValue::Integer(0))
);
assert_eq!(value(&picture, "CURRENTX"), Some(PropertyValue::Integer(2)));
assert_eq!(
value(&picture, "CURRENTY"),
Some(PropertyValue::Integer(-3))
);
let mut combo = vec![0; 33];
combo[14] = 7;
combo[31] = 2;
let combo = decode(ObjectClass::ComboBox, 0, combo);
assert_eq!(value(&combo, "STYLE"), Some(PropertyValue::Integer(2)));
let mut text = vec![0; 21];
text[19] = 1;
text[20] = 3;
let text = decode_object(
&BinarySymbol {
offset: 0,
class: ObjectClass::TextBox,
name: "Text1".into(),
is_array: false,
unsupported: false,
},
&BinaryRecord {
symbol: 0,
start: 0,
end: text.len(),
class_flags: 0,
common_flags: 0x08,
},
&text,
)
.node;
assert_eq!(
value(&text, "MULTILINE"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(value(&text, "SCROLLBARS"), Some(PropertyValue::Integer(3)));
let list = decode(ObjectClass::ListBox, 0x80, vec![0; 23]);
assert_eq!(value(&list, "SORTED"), Some(PropertyValue::Boolean(true)));
let label = decode(ObjectClass::Label, 0x20, vec![0; 21]);
assert_eq!(
value(&label, "AUTOSIZE"),
Some(PropertyValue::Boolean(true))
);
let separator = decode(ObjectClass::Menu, 0x01, vec![0; 19]);
assert_eq!(
value(&separator, "SEPARATOR"),
Some(PropertyValue::Boolean(true))
);
assert_eq!(
value(&separator, "CHECKED"),
Some(PropertyValue::Boolean(false))
);
let checked = decode(ObjectClass::Menu, 0x40, vec![0; 19]);
assert_eq!(
value(&checked, "SEPARATOR"),
Some(PropertyValue::Boolean(false))
);
assert_eq!(
value(&checked, "CHECKED"),
Some(PropertyValue::Boolean(true))
);
let mut timer = vec![0; 19];
timer[17..19].copy_from_slice(&1000u16.to_le_bytes());
let timer = decode(ObjectClass::Timer, 0, timer);
assert_eq!(
value(&timer, "INTERVAL"),
Some(PropertyValue::Integer(1000))
);
let mut scroll = vec![0; 25];
scroll[14] = 1;
for (offset, setting) in [(17, 2i16), (19, 3), (21, 10), (23, -4)] {
scroll[offset..offset + 2].copy_from_slice(&setting.to_le_bytes());
}
let scroll = decode(ObjectClass::VScrollBar, 0, scroll);
assert_eq!(
value(&scroll, "ATTACHED"),
Some(PropertyValue::Boolean(false))
);
assert_eq!(
value(&scroll, "SMALLCHANGE"),
Some(PropertyValue::Integer(2))
);
assert_eq!(
value(&scroll, "LARGECHANGE"),
Some(PropertyValue::Integer(3))
);
assert_eq!(value(&scroll, "MAX"), Some(PropertyValue::Integer(10)));
assert_eq!(value(&scroll, "MIN"), Some(PropertyValue::Integer(-4)));
assert_eq!(value(&scroll, "VALUE"), Some(PropertyValue::Integer(-4)));
assert_eq!(value(&scroll, "BACKCOLOR"), None);
let mut option = vec![0; 21];
option[19..21].copy_from_slice(&u16::MAX.to_le_bytes());
let option = decode(ObjectClass::OptionButton, 0, option);
assert_eq!(value(&option, "VALUE"), Some(PropertyValue::Integer(-1)));
}
#[test]
fn binary_layout_preserves_arrays_and_container_hierarchy() {
let symbols = vec![
BinarySymbol {
offset: 0,
class: ObjectClass::Form,
name: "Form1".into(),
is_array: false,
unsupported: false,
},
BinarySymbol {
offset: 0,
class: ObjectClass::Frame,
name: "Frame1".into(),
is_array: false,
unsupported: false,
},
BinarySymbol {
offset: 0,
class: ObjectClass::PictureBox,
name: "Picture1".into(),
is_array: true,
unsupported: false,
},
];
let mut bytes = vec![0; 126];
bytes[31..38].copy_from_slice(&[1, ObjectClass::Frame.id(), 0, 0, 0x81, 0, 0]);
bytes[38..40].copy_from_slice(&10u16.to_le_bytes());
bytes[57..64].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]);
bytes[64..66].copy_from_slice(&41u16.to_le_bytes());
bytes[88..95].copy_from_slice(&[2, ObjectClass::PictureBox.id(), 0, 0, 0xc1, 0, 0]);
bytes[95..97].copy_from_slice(&41u16.to_le_bytes());
bytes[68..70].copy_from_slice(&0u16.to_le_bytes());
bytes[99..101].copy_from_slice(&1u16.to_le_bytes());
let records = read_object_records(&symbols, &bytes, &mut |_| true).unwrap();
let decoded: Vec<_> = records
.iter()
.map(|record| decode_object(&symbols[record.symbol], record, &bytes))
.collect();
let root = build_tree(decoded).unwrap();
assert_eq!(root.children[0].children.len(), 2);
assert_eq!(
root.children[0].children[1]
.properties
.get(&forms::property(ObjectClass::PictureBox, "INDEX").unwrap().0),
Some(&PropertyValue::Integer(1))
);
}
#[test]
fn binary_strings_are_decoded_from_cp437() {
assert_eq!(decode_cp437(&[0x0e, b' ', 0x84]), "♫ ä");
assert_eq!(decode_cp437(b"Cut\tCtrl+X"), "Cut\tCtrl+X");
}
}