feat(xfl): parse native edge attributes (M4) (#1232)

This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-13 21:12:36 -05:00
1 parent 33958638b2
commit 545155124f
3 files changed
+159 -1

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "craft-xfl"
description = "Bounded reader for the ZIP package used by Animate XFL/FLA documents."
description = "Bounded XFL/FLA package reader and native shape-edge parser."
version.workspace = true
edition.workspace = true
license.workspace = true
+152
View File
@@ -0,0 +1,152 @@
//! XFL's compact shape-edge attribute grammar.
//!
//! An edge's `edges` attribute uses twips (1/20 pixel): `!x y` moves, `|x y` and `/x y` draw a
//! line, `[cx cy x y` and `]cx cy x y` draw a quadratic curve, and `Sn` marks selection state.
//! Coordinates may be decimal or signed 24.8 fixed-point hexadecimal (`#A.8` is 10.5).
//!
//! This module returns neutral points and segments so consumers can map them to their own
//! geometry model. Like Animate files in the wild, a damaged suffix ends parsing while keeping
//! the valid prefix. The segment cap bounds output allocation.
/// Most segments parsed from one edge attribute.
pub const MAX_EDGE_SEGMENTS: usize = 2_000_000;
/// A point in XFL's edge geometry, expressed in pixels after twips conversion.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Point {
pub x: f32,
pub y: f32,
}
/// One line (`control` is absent) or quadratic curve segment.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Segment {
pub from: Point,
pub control: Option<Point>,
pub to: Point,
}
const TWIPS_PER_PIXEL: f32 = 20.0;
fn number(input: &[u8], at: &mut usize) -> Option<f32> {
while input.get(*at).is_some_and(|b| b.is_ascii_whitespace()) {
*at += 1;
}
let start = *at;
if input.get(*at) == Some(&b'#') {
*at += 1;
let integer_start = *at;
while input.get(*at).is_some_and(u8::is_ascii_hexdigit) {
*at += 1;
}
let integer = std::str::from_utf8(input.get(integer_start..*at)?).ok()?;
let mut fraction = 0_u32;
if input.get(*at) == Some(&b'.') {
*at += 1;
let fraction_start = *at;
while input.get(*at).is_some_and(u8::is_ascii_hexdigit) && *at - fraction_start < 2 {
*at += 1;
}
let digits = std::str::from_utf8(input.get(fraction_start..*at)?).ok()?;
// A single hex digit is the high nibble of the 8-bit fractional part.
fraction = u32::from_str_radix(digits, 16).ok()? << if digits.len() == 1 { 4 } else { 0 };
}
let whole = u32::from_str_radix(if integer.is_empty() { "0" } else { integer }, 16).ok()?;
let fixed = whole.wrapping_shl(8) | fraction;
return Some((fixed as i32) as f32 / 256.0);
}
if matches!(input.get(*at), Some(b'-' | b'+')) {
*at += 1;
}
while input.get(*at).is_some_and(|b| b.is_ascii_digit() || matches!(b, b'.' | b'e' | b'E')) {
*at += 1;
}
std::str::from_utf8(input.get(start..*at)?).ok()?.parse::<f32>().ok().filter(|v| v.is_finite())
}
fn point(input: &[u8], at: &mut usize) -> Option<Point> {
Some(Point { x: number(input, at)? / TWIPS_PER_PIXEL, y: number(input, at)? / TWIPS_PER_PIXEL })
}
/// Parses an XFL `<Edge edges="…">` attribute into pixel-coordinate line and quadratic segments.
///
/// Stops at the first unknown or malformed command, preserving the valid prefix. The current
/// point starts at the origin. Move and selection commands do not produce segments.
pub fn parse_edge_segments(source: &str) -> Vec<Segment> {
let input = source.as_bytes();
let mut at = 0;
let mut current = Point::default();
let mut segments = Vec::new();
while let Some(&command) = input.get(at) {
at += 1;
match command {
b'!' => match point(input, &mut at) {
Some(p) => current = p,
None => break,
},
b'|' | b'/' => match point(input, &mut at) {
Some(to) => {
segments.push(Segment { from: current, control: None, to });
current = to;
}
None => break,
},
b'[' | b']' => match (point(input, &mut at), point(input, &mut at)) {
(Some(control), Some(to)) => {
segments.push(Segment { from: current, control: Some(control), to });
current = to;
}
_ => break,
},
b'S' => {
while input.get(at).is_some_and(u8::is_ascii_digit) {
at += 1;
}
}
b if b.is_ascii_whitespace() => {}
_ => break,
}
if segments.len() >= MAX_EDGE_SEGMENTS {
break;
}
}
segments
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_line_quadratic_and_twips() {
let segments = parse_edge_segments("!0 0|200 0[400 0 400 200S2/0 200");
assert_eq!(segments.len(), 3);
assert_eq!(segments[0].to, Point { x: 10.0, y: 0.0 });
assert_eq!(segments[1].control, Some(Point { x: 20.0, y: 0.0 }));
assert_eq!(segments[2], Segment { from: Point { x: 20.0, y: 10.0 }, control: None, to: Point { x: 0.0, y: 10.0 } });
}
#[test]
fn parses_signed_24_8_fixed_hex_coordinates() {
let segments = parse_edge_segments("!#A.8 -20|#FFFFFF.80 0");
assert_eq!(segments.len(), 1);
assert_eq!(segments[0].from, Point { x: 0.525, y: -1.0 });
assert_eq!(segments[0].to, Point { x: -0.025, y: 0.0 });
}
#[test]
fn malformed_suffix_keeps_only_valid_prefix() {
let segments = parse_edge_segments("!0 0|200 0[400 0 nope 200");
assert_eq!(segments, [Segment { from: Point::default(), control: None, to: Point { x: 10.0, y: 0.0 } }]);
assert!(parse_edge_segments("!1 2|x").is_empty());
assert!(parse_edge_segments("!NaN 2|5 5").is_empty());
}
#[test]
fn oversized_coordinate_numbers_and_segment_lists_are_bounded() {
assert!(parse_edge_segments("!999999999999999999999999999999999999999999999999 0|1 1").is_empty());
let source = "|0 0".repeat(MAX_EDGE_SEGMENTS + 1);
let segments = parse_edge_segments(&source);
assert_eq!(segments.len(), MAX_EDGE_SEGMENTS);
}
}
+6
View File
@@ -15,10 +15,16 @@
//! its bytes ([`MAX_RATIO`]), more than [`MAX_ENTRY`], or a size or checksum it doesn't have;
//! and the archive hands out at most [`MAX_TOTAL`] bytes in all. Nothing is ever written to
//! disk. Never panics.
//!
//! [`edges`] decodes native shape-edge attributes into neutral points and line/quadratic
//! segments for consumers to map into their own geometry models.
use std::cell::Cell;
use std::collections::HashMap;
/// XFL's edge-attribute grammar, independent of any consuming application's geometry types.
pub mod edges;
/// Largest single entry inflated (bytes).
pub const MAX_ENTRY: usize = 256 * 1024 * 1024;
/// Most bytes one archive hands out, over every read (the import's memory budget).