feat(fls): topology of a .fls file as Java loads it

readTopology maps the decoded JavaBeans graph to devices, cables and
documentation items the way SzenarioVerwaltung.netzwerkLaden does: the
kind from the Java class (the palette label is not file data), devices
without bounds at 0,0, interface values through Java's setters. All 23
corpus files match Java field by field.

Found on the way:
- listItems mixed a constructor's elements with added ones; a router's
  second interface was lost. Lists now replay add/set in order after the
  elements the constructor made.
- The first 0 of a file may be SwingConstants.CENTER; measured like the
  getter defaults.
- IP.ipCheck (javaIp.ts) measured on 46 cases: empty tokens skipped,
  signs, leading zeros and BMP digits of any script accepted.
- The Java oracle took lower-case MACs in a file for random ones (8
  interfaces); it now compares case-insensitively and reports the host
  name flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:33:02 +00:00
co-authored by Claude Opus 5.5
parent a02b78b4cb
commit a02bb62413
18 changed files with 1446 additions and 34 deletions
+2
View File
@@ -1,4 +1,6 @@
// Public surface of the .fls codec.
export * from './archive.js';
export * from './javaIp.js';
export * from './javabeans.js';
export * from './topology.js';
export * from './zip.js';
+1
View File
@@ -11,6 +11,7 @@
"RIPServer#port": 520,
"Rechner#maxLayerOfOperation": 4,
"ServerBaustein#port": 55555,
"SwingConstants.CENTER": 0,
"ThreadGroup#maxPriority": 10,
"Vermittlungsrechner#maxLayerOfOperation": 2,
"WebServer#port": 80
+65
View File
@@ -0,0 +1,65 @@
// What Java FILIUS stores when a file sets an interface's IP address or mask:
// NetzwerkInterface.setIp(ip) keeps IP.ipCheck(ip), which is null for text
// that is not an address and the canonical dotted quad otherwise.
// Java counterpart: filius.software.vermittlungsschicht.IP.inetAton/ipCheck.
//
// Measured against Java (test/ip-check.cases.txt, tools/java-oracle), since
// the rules are wider than they look:
// - StringTokenizer(".") skips empty tokens: "10..0.0.1" is 10.0.0.1;
// - Integer.parseInt takes a sign and leading zeros: "+010" is 10, "-0" is 0;
// - and any Unicode decimal digit in the BMP: Arabic-Indic "١٠" is 10 —
// parseInt reads UTF-16 units, so "𝟏" (a surrogate pair) is no digit.
/**
* `Integer.parseInt(text)`: an optional sign, then one or more decimal digits
* of any script; undefined where Java throws NumberFormatException.
*/
export function javaParseInt(text: string): number | undefined {
const characters = text.split('');
let index = 0;
let negative = false;
if (characters[0] === '-' || characters[0] === '+') {
negative = characters[0] === '-';
index = 1;
}
if (index >= characters.length) return undefined;
let value = 0;
for (; index < characters.length; index++) {
const digit = decimalDigitValue(characters[index]!);
if (digit === undefined) return undefined;
value = value * 10 + digit;
// Past the int range Java throws; the octet check would reject it anyway.
if (value > 2 ** 31) return undefined;
}
if (!negative && value > 2 ** 31 - 1) return undefined;
return negative ? -value : value;
}
/**
* `Character.digit(c, 10)` for one UTF-16 unit: Unicode lays out every
* script's digits as a run of ten from zero to nine, so the value is the
* distance from the start of the run, modulo ten (in case two runs touch).
*/
function decimalDigitValue(unit: string): number | undefined {
if (!/^\p{Nd}$/u.test(unit)) return undefined;
let code = unit.charCodeAt(0);
let offset = 0;
while (/^\p{Nd}$/u.test(String.fromCharCode(code - 1))) {
code -= 1;
offset += 1;
}
return offset % 10;
}
/** `IP.ipCheck(text)`: four octets 0…255 as `a.b.c.d`, or null. */
export function javaIpCheck(text: string): string | null {
const tokens = text.split('.').filter((token) => token !== '');
if (tokens.length !== 4) return null;
const octets: number[] = [];
for (const token of tokens) {
const octet = javaParseInt(token);
if (octet === undefined || octet < 0 || octet > 255) return null;
octets.push(octet);
}
return octets.join('.');
}
+62 -18
View File
@@ -9,11 +9,12 @@
// cannot follow values XMLEncoder shares by reference (plan §13, M2):
// - `id`/`idref` resolve anywhere, including the results of getters;
// - a getter on a property nobody set answers the value Java would, from
// measured defaults (javaDefaults.json), or an explicit "unresolved".
// measured defaults (javaDefaults.json), or an explicit "unresolved";
// - static constants (`SwingConstants.CENTER`) are their measured values.
//
// XMLDecoder's rules, as far as XMLEncoder uses them:
// <object class="C">args… statements…</object> new C(args), then the statements
// <object class="C" field="F"/> the static field C.F
// <object class="C" field="F"/> the static field C.F (measured value if known)
// <object class="C" method="m">args</object> the static factory C.m(args)
// <object idref="X"/> the value stored under id X
// <void property="p"/> target.getP() (a value)
@@ -46,8 +47,12 @@ export interface JavaObject {
readonly properties: Map<string, JavaValue>;
/** Method calls in order (`add`, `put`, …), with their arguments. */
readonly calls: { method: string; args: JavaValue[] }[];
/** `set(i, v)` on the object. */
readonly indexed: Map<number, JavaValue>;
/**
* Elements the object already held before the file's statements — a list
* its constructor filled, reached by `<void index="i">` on a getter's
* result. `add`/`set` go to `calls`, in order.
*/
readonly presetElements: Map<number, JavaValue>;
/** True when the object was not in the file but returned by a getter. */
readonly fromGetter: boolean;
}
@@ -97,14 +102,40 @@ export function simpleClassName(className: string): string {
return className.slice(className.lastIndexOf('.') + 1);
}
/** The elements of a list: `add` calls, then `set(i, v)` replacing by index. */
export function listItems(value: JavaValue | undefined): JavaValue[] {
/**
* The elements of a list: the ones it held before the file's statements,
* then `add`, `add(i, v)` and `set(i, v)` replayed in order.
*
* `constructed` is how many elements the list's constructor made — a Java
* `Switch` starts with 24 ports, a `Gateway` with two interfaces. The file
* only mentions those it changed; the rest come back as untouched objects.
*/
export function listItems(value: JavaValue | undefined, constructed = 0): JavaValue[] {
if (typeof value === 'object' && value !== null && (value as JavaArray).kind === 'array') {
return [...(value as JavaArray).items];
}
if (!isJavaObject(value)) return [];
const items = value.calls.filter((call) => call.method === 'add').map((call) => call.args.at(-1) ?? null);
for (const [index, item] of value.indexed) items[index] = item;
const items = replayList(value, constructed);
return Array.from(items, (item) => item ?? newObject({ className: 'unknown', fromGetter: true }));
}
/** Like listItems, with `undefined` for a constructed element nobody touched. */
function replayList(value: JavaObject, constructed: number): (JavaValue | undefined)[] {
const presetLength = Math.max(constructed, ...Array.from(value.presetElements.keys(), (index) => index + 1));
const items: (JavaValue | undefined)[] = Array.from({ length: presetLength }, (_, index) =>
value.presetElements.get(index),
);
for (const { method, args } of value.calls) {
if (method === 'add' && args.length === 1) {
items.push(args[0]!);
} else if (method === 'add' && args.length === 2 && typeof args[0] === 'number') {
items.splice(args[0], 0, args[1]!);
} else if (method === 'set' && args.length === 2 && typeof args[0] === 'number') {
items[args[0]] = args[1]!;
} else if (method === 'clear' && args.length === 0) {
items.length = 0;
}
}
return items;
}
@@ -194,7 +225,7 @@ function newObject(fields: Partial<JavaObject> & { className: string }): JavaObj
factoryMethod: fields.factoryMethod,
properties: new Map(),
calls: [],
indexed: new Map(),
presetElements: new Map(),
fromGetter: fields.fromGetter ?? false,
};
}
@@ -297,6 +328,12 @@ class Decoder {
}
const className = attributes.class;
if (className === undefined) throw new JavaBeansError('<object> without class or idref.');
if (attributes.field !== undefined) {
// A static constant: XMLEncoder writes a shared Integer 0 as
// SwingConstants.CENTER when that was the first 0 it met.
const constantKey = `${simpleClassName(className)}.${attributes.field}`;
if (Object.hasOwn(DEFAULTS, constantKey)) return this.primitive(element, DEFAULTS[constantKey]!);
}
const args = element.children.filter((child) => child.name !== 'void');
const object = newObject({
className,
@@ -342,7 +379,7 @@ class Decoder {
: this.setProperty(target, attributes.property, args[0]!);
} else if (attributes.index !== undefined) {
const index = Number.parseInt(attributes.index, 10);
value = args.length === 0 ? this.getIndex(target, index) : this.setIndex(target, index, args[0]!);
value = args.length === 0 ? this.getIndex(target, index, attributes.id) : this.setIndex(target, index, args[0]!);
} else if (attributes.method !== undefined) {
value = this.call(target, attributes.method, args, attributes.id);
} else if (attributes.idref !== undefined) {
@@ -374,7 +411,7 @@ class Decoder {
}
if (isJavaObject(target)) {
target.calls.push({ method, args });
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0]);
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id);
}
return null;
}
@@ -405,17 +442,24 @@ class Decoder {
return placeholder;
}
private getIndex(target: JavaValue | undefined, index: number): JavaValue {
private getIndex(target: JavaValue | undefined, index: number, id: string | undefined): JavaValue {
if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
return (target as JavaArray).items[index] ?? null;
}
if (!isJavaObject(target)) return { kind: 'unresolved', reason: `index ${index} on a value that is not an object` };
const existing = listItems(target)[index];
const items = replayList(target, 0);
const existing = items[index];
if (existing !== undefined) return existing;
// A list the file never filled, like a node's interface list created by
// its constructor: the element is a placeholder that later reads return.
const placeholder = newObject({ className: 'unknown', fromGetter: true });
target.indexed.set(index, placeholder);
// An element the list held before the file touched it, like a node's
// interface list created by its constructor: a placeholder that later
// reads return. XMLEncoder writes these before any `add`; after one, the
// index could mean either, and this reader does not guess.
const changed = target.calls.some((call) => call.method === 'add' || call.method === 'set' || call.method === 'clear');
if (changed && index >= items.length) {
return { kind: 'unresolved', reason: `index ${index} after the list was changed` };
}
const placeholder = newObject({ className: id?.replace(/\d+$/, '') ?? 'unknown', id, fromGetter: true });
target.presetElements.set(index, placeholder);
return placeholder;
}
@@ -423,7 +467,7 @@ class Decoder {
if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
(target as JavaArray).items[index] = value;
} else if (isJavaObject(target)) {
target.indexed.set(index, value);
target.calls.push({ method: 'set', args: [index, value] });
}
return null;
}
+408
View File
@@ -0,0 +1,408 @@
// The network drawn in a .fls file — devices, cables, documentation items —
// read from the decoded JavaBeans graph (javabeans.ts) the way Java FILIUS
// loads it (SzenarioVerwaltung.netzwerkLaden). Only the topology: system
// software, file systems and applications are later layers, which start
// from `FlsNode.source`.
//
// The result is Java's view, indexed like Java's lists, and is checked
// against Java itself (corpus.java.json). The editor model (node kinds, UUIDs,
// ports as Swift's TopologyPortMetadata, runtime configurations) is built
// from it in a separate step.
//
// Where this departs from filius-ipad's reader, Java is right (plan §13, M2):
// - the device kind comes from the Java class; the palette label ("Heimrouter")
// is not file data — Java overwrites it with the class's type name;
// - a device without coordinates stands at 0,0, as in Java, instead of
// being dropped;
// - interface values go through Java's setters (IP.ipCheck for address and
// mask, upper-cased MAC).
import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaIpCheck } from './javaIp.js';
export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem';
export interface FlsInterface {
/** After IP.ipCheck: a canonical dotted quad, or null for text that is not one. */
ip: string | null;
mask: string | null;
/** Upper-cased, as getMac() returns it; undefined when the file has none (Java rolls a random one). */
mac: string | undefined;
gateway: string | null;
dns: string | null;
}
export interface FlsNode {
javaClass: FlsNodeClass;
name: string | null;
x: number;
y: number;
/** Hosts only (Rechner, Notebook); false for the others. */
useIpAsName: boolean;
useMacAsName: boolean;
/** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */
interfaces: FlsInterface[];
/** Interfaces, or the ports of a Switch or Modem. */
portCount: number;
/** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */
source: JavaObject;
}
/** A cable end: the node's index and the port's index on it, as in Java's lists. */
export interface FlsPortReference {
node: number;
port: number;
}
export interface FlsLink {
ends: [FlsPortReference, FlsPortReference];
wireless: boolean;
}
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
export interface FlsFont {
name: string;
size: number;
style: number;
}
export interface FlsDocumentationItem {
/** GUIDocuItem.RECT = 1, TEXT = 2; 0 when the file sets none. */
type: number;
text: string | null;
x: number;
y: number;
width: number;
height: number;
color: FlsColor | null;
font: FlsFont | null;
}
export interface FlsTopology {
/** The version line newer files start with ("Filius version: 1.7.4 (…)"), else null. */
versionLine: string | null;
nodes: FlsNode[];
links: FlsLink[];
documentation: FlsDocumentationItem[];
/** What Java would read and this reader could not, and what it left out. */
warnings: string[];
}
interface NodeClassInfo {
/** Elements the constructor creates: interfaces (InternetKnoten) or ports (LokalerKnoten). */
constructed: number;
local: boolean;
host: boolean;
/**
* The name the constructor gives, in German — the locale Java FILIUS starts
* in (and the one corpus.java.json was measured with). An English FILIUS
* would say "Computer" or "Router" here.
*/
defaultName: string;
}
const NODE_CLASSES: Record<FlsNodeClass, NodeClassInfo> = {
Rechner: { constructed: 1, local: false, host: true, defaultName: 'Rechner' },
Notebook: { constructed: 1, local: false, host: true, defaultName: 'Notebook' },
Vermittlungsrechner: {
constructed: 1,
local: false,
host: false,
defaultName: 'Vermittlungsrechner',
},
Gateway: { constructed: 2, local: false, host: false, defaultName: 'Heimrouter' },
Switch: { constructed: 24, local: true, host: false, defaultName: 'Switch / WLAN' },
Modem: { constructed: 1, local: true, host: false, defaultName: 'Modem' },
};
const DEVICE_PACKAGE = 'filius.hardware.knoten.';
function isFlsNodeClass(name: string): name is FlsNodeClass {
return Object.hasOwn(NODE_CLASSES, name);
}
/** Maps the top-level objects of `konfiguration.xml` to the topology. */
export function readTopology(values: readonly JavaValue[]): FlsTopology {
const warnings: string[] = [];
const context = new Reader(warnings);
// netzwerkLaden: an optional version string, then the node list, the
// cable list and the documentation list, each by position. A list counts
// only if its first element has the expected class.
let position = 0;
let versionLine: string | null = null;
if (typeof values[0] === 'string') {
versionLine = values[0];
position = 1;
}
const nodeItems = listOf(values[position], 'filius.gui.netzwerksicht.GUIKnotenItem');
const cableItems = listOf(values[position + 1], 'filius.gui.netzwerksicht.GUIKabelItem');
const documentationItems = listOf(values[position + 2], 'filius.gui.netzwerksicht.GUIDocuItem');
const nodes: FlsNode[] = [];
for (const [index, item] of nodeItems.entries()) {
const node = context.node(item, index, nodes.length);
if (node !== undefined) nodes.push(node);
}
const links: FlsLink[] = [];
for (const [index, item] of cableItems.entries()) {
const link = context.link(item, index);
if (link !== undefined) links.push(link);
}
const documentation = documentationItems.flatMap((item) => (isJavaObject(item) ? [context.documentation(item)] : []));
return { versionLine, nodes, links, documentation, warnings };
}
/** The elements of `value` if it is a list whose first element is an object of `className`. */
function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
const items = listItems(value);
const first = items[0];
return isJavaObject(first) && first.className === className ? items : [];
}
class Reader {
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
constructor(private readonly warnings: string[]) {}
node(item: JavaValue, itemIndex: number, nodeIndex: number): FlsNode | undefined {
const device = isJavaObject(item) ? item.properties.get('knoten') : undefined;
if (!isJavaObject(device)) {
this.warnings.push(`node ${itemIndex + 1}: no device (knoten), left out`);
return undefined;
}
const simpleName = simpleClassName(device.className);
if (!device.className.startsWith(DEVICE_PACKAGE) || !isFlsNodeClass(simpleName)) {
this.warnings.push(`node ${itemIndex + 1}: unknown device class ${device.className}, left out`);
return undefined;
}
const info = NODE_CLASSES[simpleName];
const [x, y] = this.position(item as JavaObject, itemIndex);
const node: FlsNode = {
javaClass: simpleName,
name: this.stringProperty(device, 'name', info.defaultName, `node ${itemIndex + 1}`),
x,
y,
useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`),
useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`),
interfaces: [],
portCount: 0,
source: device,
};
if (info.local) {
const ports = this.constructedList(device, 'anschluesse', info.constructed);
node.portCount = ports.length;
ports.forEach((port, portIndex) => this.ownPort(port, nodeIndex, portIndex));
} else {
const networkInterfaces = this.constructedList(device, 'netzwerkInterfaces', info.constructed);
node.interfaces = networkInterfaces.map((nic, nicIndex) =>
this.networkInterface(nic, simpleName === 'Gateway' && nicIndex === 0, `node ${itemIndex + 1}`),
);
node.portCount = networkInterfaces.length;
networkInterfaces.forEach((nic, nicIndex) => {
if (isJavaObject(nic)) this.ownPort(nic.properties.get('port'), nodeIndex, nicIndex);
});
}
return node;
}
/**
* A list property the constructor filled: a getter's result keeps the
* constructed elements, a list the file sets replaces them.
*/
private constructedList(device: JavaObject, property: string, constructed: number): JavaValue[] {
const list = device.properties.get(property);
// Untouched: the constructed elements, which carry nothing but defaults.
if (list === undefined) return Array.from({ length: constructed }, () => null);
return listItems(list, isJavaObject(list) && list.fromGetter ? constructed : 0);
}
private ownPort(port: JavaValue | undefined, node: number, index: number): void {
if (isJavaObject(port) && !this.portOwners.has(port)) this.portOwners.set(port, { node, port: index });
}
/**
* The imageLabel's bounds: fields set one by one in newer files,
* `new Rectangle(x, y, width, height)` in older ones. Without either, Java's
* label sits at 0,0.
*/
private position(item: JavaObject, itemIndex: number): [number, number] {
const label = item.properties.get('imageLabel');
const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined;
if (!isJavaObject(bounds)) return [0, 0];
return (['x', 'y'] as const).map((field, index) => {
const value = bounds.properties.get(field) ?? bounds.args[index] ?? 0;
if (typeof value === 'number') return value;
this.warnings.push(`node ${itemIndex + 1}: position ${field} is ${describe(value)}, used 0`);
return 0;
}) as [number, number];
}
/**
* NetzwerkInterface as its constructor leaves it, then the file's setters.
* A Gateway's constructor gives its first interface (WAN) its own address.
*/
private networkInterface(nic: JavaValue, gatewayWan: boolean, where: string): FlsInterface {
const result: FlsInterface = {
ip: gatewayWan ? '42.0.0.10' : '192.168.0.10',
mask: gatewayWan ? '255.0.0.0' : '255.255.255.0',
mac: undefined,
gateway: '',
dns: '',
};
if (!isJavaObject(nic)) return result;
// Only the constructor's own interface gets the WAN address; one the file
// creates with `new NetzwerkInterface()` starts like any other.
if (gatewayWan && !nic.fromGetter) {
result.ip = '192.168.0.10';
result.mask = '255.255.255.0';
}
const at = `${where}, interface`;
for (const [property, field] of [
['ip', 'ip'],
['subnetzMaske', 'mask'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
// setIp(null) throws inside Java's setter; XMLDecoder swallows that and
// the constructor's value stays.
if (typeof value === 'string') result[field] = javaIpCheck(value);
else this.unexpected(at, property, value);
}
const mac = nic.properties.get('mac');
if (typeof mac === 'string') result.mac = mac.toUpperCase();
else if (mac !== undefined && mac !== null) this.unexpected(at, 'mac', mac);
for (const [property, field] of [
['gateway', 'gateway'],
['dns', 'dns'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
if (typeof value === 'string' || value === null) result[field] = value;
else this.unexpected(at, property, value);
}
return result;
}
link(item: JavaValue, itemIndex: number): FlsLink | undefined {
const cable = isJavaObject(item) ? item.properties.get('dasKabel') : undefined;
if (!isJavaObject(cable)) {
this.warnings.push(`cable ${itemIndex + 1}: no cable (dasKabel), left out`);
return undefined;
}
// Kabel.setAnschluesse(Port[]) in newer files, new Kabel(port, port) in older ones.
const declared = cable.properties.get('anschluesse');
const ports = declared === undefined ? cable.args : listItems(declared);
const ends = ports.map((port) => (isJavaObject(port) ? this.portOwners.get(port) : undefined));
const [first, second] = ends;
if (ends.length !== 2 || first === undefined || second === undefined) {
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
return undefined;
}
return {
ends: [first, second],
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
};
}
documentation(item: JavaObject): FlsDocumentationItem {
const where = 'documentation item';
const integer = (property: string): number => {
const value = item.properties.get(property);
if (value === undefined) return 0;
if (typeof value === 'number' && Number.isInteger(value)) return value;
this.unexpected(where, property, value);
return 0;
};
const text = item.properties.get('text');
return {
type: integer('type'),
text: typeof text === 'string' ? text : null,
x: integer('x'),
y: integer('y'),
width: integer('width'),
height: integer('height'),
color: this.color(item.properties.get('color'), where),
font: this.font(item.properties.get('font'), where),
};
}
/**
* `java.awt.Color` as XMLEncoder writes it: `new Color(r, g, b, a)`. Also
* `new Color(rgb)` and `new Color(r, g, b)` (ColorUIResource). A component
* outside 0…255 makes Java's constructor throw — the color stays unset.
*/
private color(value: JavaValue | undefined, where: string): FlsColor | null {
if (value === undefined || value === null) return null;
if (!isJavaObject(value)) {
this.unexpected(where, 'color', value);
return null;
}
const args = value.args;
if (!args.every((arg) => typeof arg === 'number' && Number.isInteger(arg))) {
this.warnings.push(`${where}: color ${value.className} with arguments this reader does not know, left unset`);
return null;
}
const numbers = args as number[];
if (numbers.length === 1) {
const rgb = numbers[0]!;
return [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255];
}
if (numbers.length === 3 || numbers.length === 4) {
const rgba = numbers.length === 3 ? [...numbers, 255] : numbers;
if (rgba.every((component) => component >= 0 && component <= 255)) return rgba as FlsColor;
this.warnings.push(`${where}: color component outside 0…255, left unset (as in Java)`);
return null;
}
this.warnings.push(`${where}: color with ${numbers.length} arguments, left unset`);
return null;
}
/** `new Font(name, style, size)`, also as FontUIResource. Java keeps style 0…3 and makes others 0. */
private font(value: JavaValue | undefined, where: string): FlsFont | null {
if (value === undefined || value === null) return null;
const [name, style, size] = isJavaObject(value) ? value.args : [];
if (
!isJavaObject(value) ||
value.args.length !== 3 ||
(typeof name !== 'string' && name !== null) ||
typeof style !== 'number' ||
typeof size !== 'number'
) {
this.unexpected(where, 'font', value);
return null;
}
return { name: name ?? 'Default', size, style: (style & ~0x03) === 0 ? style : 0 };
}
private stringProperty(target: JavaObject, property: string, fallback: string, where: string): string | null {
const value = target.properties.get(property);
if (value === undefined) return fallback;
if (typeof value === 'string' || value === null) return value;
this.unexpected(where, property, value);
return fallback;
}
private booleanProperty(target: JavaObject, property: string, where: string): boolean {
const value = target.properties.get(property);
if (value === undefined) return false;
if (typeof value === 'boolean') return value;
this.unexpected(where, property, value);
return false;
}
private unexpected(where: string, property: string, value: JavaValue): void {
this.warnings.push(`${where}: ${property} is ${describe(value)}, ignored`);
}
}
function describe(value: JavaValue): string {
if (isUnresolved(value)) return `unresolved (${value.reason})`;
if (isJavaObject(value)) return `an object of ${value.className}`;
return JSON.stringify(typeof value === 'bigint' ? String(value) : value) ?? String(value);
}