2271 lines
76 KiB
Rust
2271 lines
76 KiB
Rust
//! 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");
|
||
}
|
||
}
|