//! 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, pub children: Vec, } #[derive(Debug, Clone)] pub struct FormFile { pub version: String, pub root: FormNode, pub code: String, original: Option, } #[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, root: FormNode, code: impl Into) -> 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, tb_runtime::errors::RuntimeError> { fn collect( node: &FormNode, parent: Option<(u16, Option)>, catalog: &FormCatalog, form: &str, out: &mut Vec, 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, } 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, } pub(crate) fn error( file: &str, line: usize, name: impl Into, message: impl Into, ) -> FormError { FormError { file: file.into(), line, name: name.into(), message: message.into(), } } pub fn read_text(file: &str, source: &str) -> Result { 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 { 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 { 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::() .map_err(|_| bad(format!("ungueltiger Wert {raw}")))?; validate_range(file, line, object, spec, number)?; Ok(PropertyValue::Integer(number)) } PropertyType::Single => raw .parse::() .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 { 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::>(); 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 { 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, 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> { 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::>(); 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, seen: &mut [u8], failed: &mut HashSet<(usize, usize, Vec)>, 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 { (start + offset < end).then(|| bytes[start + offset]) } fn record_u16(bytes: &[u8], start: usize, end: usize, offset: usize) -> Option { (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) -> Option { 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, 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::>(); 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::>(); 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, used: &mut HashSet, 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> { 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 = 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 = 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 = 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 = 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 = 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 = 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::>(); let mut decoded = records .iter() .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) .collect::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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::>(); let mut decoded = records .iter() .map(|record| decode_object(&symbols[record.symbol], record, &bytes)) .collect::>(); 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| { 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"); } }