From 21e98d1b2b7dccc4f5af408a71999a86863937e1 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 27 Jul 2026 07:39:49 +0000 Subject: [PATCH 01/29] Add canonical packed glyph layer codec --- Cargo.lock | 1 + crates/shift-font/Cargo.toml | 1 + crates/shift-font/docs/DOCS.md | 4 +- crates/shift-font/src/lib.rs | 2 + crates/shift-font/src/packed_layer.rs | 331 ++++ crates/shift-glyph-codec/SPECIFICATION.md | 241 ++- .../examples/layer_benchmark.rs | 136 ++ crates/shift-glyph-codec/src/layer.rs | 1722 +++++++++++++++++ crates/shift-glyph-codec/src/lib.rs | 4 + crates/shift-glyph-codec/tests/layer.rs | 347 ++++ fixtures/glyph-codec/layer-v1/empty.bin | Bin 0 -> 113 bytes fixtures/glyph-codec/layer-v1/full.bin | Bin 0 -> 945 bytes fixtures/glyph-codec/layer-v1/vectors.json | 17 + packages/glyph-codec/README.md | 45 +- packages/glyph-codec/src/error.ts | 15 + packages/glyph-codec/src/index.ts | 30 +- packages/glyph-codec/src/layer.test.ts | 295 +++ packages/glyph-codec/src/layer.ts | 1162 +++++++++++ packages/glyph-codec/src/outline.ts | 19 +- packages/glyph-codec/src/types.ts | 96 + 20 files changed, 4428 insertions(+), 40 deletions(-) create mode 100644 crates/shift-font/src/packed_layer.rs create mode 100644 crates/shift-glyph-codec/examples/layer_benchmark.rs create mode 100644 crates/shift-glyph-codec/src/layer.rs create mode 100644 crates/shift-glyph-codec/tests/layer.rs create mode 100644 fixtures/glyph-codec/layer-v1/empty.bin create mode 100644 fixtures/glyph-codec/layer-v1/full.bin create mode 100644 fixtures/glyph-codec/layer-v1/vectors.json create mode 100644 packages/glyph-codec/src/error.ts create mode 100644 packages/glyph-codec/src/layer.test.ts create mode 100644 packages/glyph-codec/src/layer.ts diff --git a/Cargo.lock b/Cargo.lock index 2af545c9..1941d3d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2243,6 +2243,7 @@ dependencies = [ "linesweeper", "serde", "serde_json", + "shift-glyph-codec", "thiserror", ] diff --git a/crates/shift-font/Cargo.toml b/crates/shift-font/Cargo.toml index 43b39fb1..8948cddc 100644 --- a/crates/shift-font/Cargo.toml +++ b/crates/shift-font/Cargo.toml @@ -17,6 +17,7 @@ indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" serde = { version = "1.0", features = ["derive", "rc"] } +shift-glyph-codec = { workspace = true } thiserror = "2.0.18" getrandom = "0.3.3" diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index 97f41e41..a24e0b91 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -22,6 +22,7 @@ crates/shift-font/src/ intents.rs -- atomic authoring intents and semantic application changes.rs -- replace-grade semantic change records layer_edit.rs -- glyph-layer geometry mutations + packed_layer.rs -- lossless shift.glyph-layer.v1 domain adapter variation.rs -- external-to-design mapping evaluation interpolation.rs -- source compatibility, reusable bases, source values projection.rs -- location-independent glyph payloads and resolved views @@ -42,6 +43,7 @@ crates/shift-font/src/ - `SourceMetricInterpolation` owns metric identity, optional technical-field participation, variation regions, and delta ordering for source-owned metrics. - `Glyph` is a glyph concept identified by `GlyphId`. - `GlyphLayer` is authored editable data for one glyph at one source. +- `pack_glyph_layer`, `unpack_glyph_layer`, and `glyph_layer_from_view` adapt one layer to/from canonical `shift.glyph-layer.v1` while validating typed IDs and glyph names at the domain boundary. - `InterpolationBasis` is coordinate-independent variation math for an ordered source set. It contains normalized supports and source coefficient rows, never glyph coordinates or metrics. - `GlyphInterpolation` combines a reusable basis with one glyph's compatible authored source values. The glyph's default-source layer owns topology when present; otherwise a deterministic master-backed reference layer allows sparse glyph interpolation. - `LayerCompatibility` records every hard structural difference between an interpolation reference layer and another source layer. `LayerDifference` retains ordered path, node, anchor, and component evidence for diagnostics. @@ -90,7 +92,7 @@ Coordinates, advance width, smooth flags, anchor positions, and component transf `shift-wire` may translate native bases, source values, and projections into transport DTOs, but it must not rebuild source samples, define value ordering or topology compatibility, or evaluate variation models. -`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. +`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. The `packed_layer` module only adapts one domain layer to codec-owned bytes/views; it does not choose storage, caching, transactions, or loading policy. `shift-font` should not own Electron, NAPI, or editor state. The TypeScript editor owns UI interaction, selection, hover, camera, tools, and command history. diff --git a/crates/shift-font/src/lib.rs b/crates/shift-font/src/lib.rs index 44c3224d..e61c06a9 100644 --- a/crates/shift-font/src/lib.rs +++ b/crates/shift-font/src/lib.rs @@ -37,6 +37,7 @@ pub mod intents; pub mod interpolation; pub mod ir; pub mod layer_edit; +pub mod packed_layer; pub mod projection; #[cfg(any(test, feature = "test-support"))] pub mod test_support; @@ -51,4 +52,5 @@ pub use ir::{ glyph_name, guideline, kerning, lib_data, metrics, named_instance, point, segment, source, variation, }; +pub use packed_layer::*; pub use projection::*; diff --git a/crates/shift-font/src/packed_layer.rs b/crates/shift-font/src/packed_layer.rs new file mode 100644 index 00000000..1418ea0f --- /dev/null +++ b/crates/shift-font/src/packed_layer.rs @@ -0,0 +1,331 @@ +//! Lossless adaptation between the authoring model and `shift.glyph-layer.v1`. +//! +//! The codec remains independent of `shift-font`; this module owns typed-ID and +//! domain-object validation at the boundary. + +use std::collections::HashMap; +use std::str::FromStr; + +use shift_glyph_codec as codec; + +use crate::{ + Anchor, AnchorId, Component, ComponentId, Contour, ContourId, DecomposedTransform, GlyphId, + GlyphLayer, GlyphName, Guideline, GuidelineId, LayerId, LibData, LibValue, Point, PointId, + PointType, SourceId, +}; + +#[derive(Debug, thiserror::Error)] +pub enum PackedLayerError { + #[error(transparent)] + Codec(#[from] codec::LayerCodecError), + + #[error("invalid {kind} {value:?}")] + InvalidId { kind: &'static str, value: String }, + + #[error("invalid base glyph name {0:?}")] + InvalidGlyphName(String), +} + +/// Encodes one editable authored layer without involving SQLite or transport. +pub fn pack_glyph_layer(layer: &GlyphLayer) -> Result { + codec::pack_layer(&to_codec_layer(layer)).map_err(Into::into) +} + +/// Validates bytes and materializes exactly one editable authored layer. +pub fn unpack_glyph_layer(bytes: &[u8]) -> Result { + glyph_layer_from_view(codec::decode_layer(bytes)?) +} + +/// Materializes one layer from an already-validated borrowed payload view. +pub fn glyph_layer_from_view( + view: codec::GlyphLayerView<'_>, +) -> Result { + let layer_id = parse_id::("layer id", view.id())?; + let source_id = parse_id::("source id", view.source_id())?; + let mut layer = GlyphLayer::with_width(layer_id, source_id, view.width()); + layer.set_height(view.height()); + + for contour in view.contours() { + let contour_id = parse_id::("contour id", contour.id())?; + let mut decoded = Contour::with_id(contour_id); + for point in contour.points() { + decoded.push_point(Point::new( + parse_id::("point id", point.id())?, + point.x(), + point.y(), + from_codec_point_type(point.point_type()), + point.smooth(), + )); + } + if contour.closed() { + decoded.close(); + } + layer.add_contour(decoded); + } + + for component in view.components() { + let name = GlyphName::new(component.base_glyph_name()).map_err(|_| { + PackedLayerError::InvalidGlyphName(component.base_glyph_name().to_owned()) + })?; + let transform = component.transform(); + layer.add_component(Component::with_id( + parse_id::("component id", component.id())?, + parse_id::("base glyph id", component.base_glyph_id())?, + name, + DecomposedTransform { + translate_x: transform.translate_x, + translate_y: transform.translate_y, + rotation: transform.rotation, + scale_x: transform.scale_x, + scale_y: transform.scale_y, + skew_x: transform.skew_x, + skew_y: transform.skew_y, + t_center_x: transform.center_x, + t_center_y: transform.center_y, + }, + )); + } + + for anchor in view.anchors() { + layer.add_anchor(Anchor::with_id( + parse_id::("anchor id", anchor.id())?, + anchor.name().map(str::to_owned), + anchor.x(), + anchor.y(), + )); + } + + for guideline in view.guidelines() { + layer.add_guideline(Guideline::with_id( + parse_id::("guideline id", guideline.id())?, + guideline.x(), + guideline.y(), + guideline.angle(), + guideline.name().map(str::to_owned), + guideline.color().map(str::to_owned), + )); + } + + *layer.lib_mut() = LibData::from_map( + view.lib() + .into_iter() + .map(|(key, value)| (key, from_codec_lib_value(value))) + .collect(), + ); + Ok(layer) +} + +fn to_codec_layer(layer: &GlyphLayer) -> codec::GlyphLayer { + codec::GlyphLayer { + id: layer.id().as_str().to_owned(), + source_id: layer.source_id().as_str().to_owned(), + width: layer.width(), + height: layer.height(), + contours: layer + .contours_iter() + .map(|contour| codec::LayerContour { + id: contour.id().as_str().to_owned(), + closed: contour.is_closed(), + points: contour + .points() + .iter() + .map(|point| codec::LayerPoint { + id: point.id().as_str().to_owned(), + point_type: to_codec_point_type(point.point_type()), + smooth: point.is_smooth(), + x: point.x(), + y: point.y(), + }) + .collect(), + }) + .collect(), + components: layer + .components_iter() + .map(|component| { + let transform = component.transform(); + codec::LayerComponent { + id: component.id().as_str().to_owned(), + base_glyph_id: component.base_glyph_id().as_str().to_owned(), + base_glyph_name: component.base_glyph_name().as_str().to_owned(), + transform: codec::LayerTransform { + translate_x: transform.translate_x, + translate_y: transform.translate_y, + rotation: transform.rotation, + scale_x: transform.scale_x, + scale_y: transform.scale_y, + skew_x: transform.skew_x, + skew_y: transform.skew_y, + center_x: transform.t_center_x, + center_y: transform.t_center_y, + }, + } + }) + .collect(), + anchors: layer + .anchors_iter() + .map(|anchor| codec::LayerAnchor { + id: anchor.id().as_str().to_owned(), + name: anchor.name().map(str::to_owned), + x: anchor.x(), + y: anchor.y(), + }) + .collect(), + guidelines: layer + .guidelines() + .iter() + .map(|guideline| codec::LayerGuideline { + id: guideline.id().as_str().to_owned(), + x: guideline.x(), + y: guideline.y(), + angle: guideline.angle(), + name: guideline.name().map(str::to_owned), + color: guideline.color().map(str::to_owned), + }) + .collect(), + lib: layer + .lib() + .iter() + .map(|(key, value)| (key.clone(), to_codec_lib_value(value))) + .collect(), + } +} + +fn to_codec_point_type(point_type: PointType) -> codec::LayerPointType { + match point_type { + PointType::OnCurve => codec::LayerPointType::OnCurve, + PointType::OffCurve => codec::LayerPointType::OffCurve, + PointType::QCurve => codec::LayerPointType::QCurve, + } +} + +fn from_codec_point_type(point_type: codec::LayerPointType) -> PointType { + match point_type { + codec::LayerPointType::OnCurve => PointType::OnCurve, + codec::LayerPointType::OffCurve => PointType::OffCurve, + codec::LayerPointType::QCurve => PointType::QCurve, + } +} + +fn to_codec_lib_value(value: &LibValue) -> codec::LayerLibValue { + match value { + LibValue::String(value) => codec::LayerLibValue::String(value.clone()), + LibValue::Integer(value) => codec::LayerLibValue::Integer(*value), + LibValue::UnsignedInteger(value) => codec::LayerLibValue::UnsignedInteger(*value), + LibValue::Float(value) => codec::LayerLibValue::Float(*value), + LibValue::Boolean(value) => codec::LayerLibValue::Boolean(*value), + LibValue::Array(values) => { + codec::LayerLibValue::Array(values.iter().map(to_codec_lib_value).collect()) + } + LibValue::Dict(values) => codec::LayerLibValue::Dict( + values + .iter() + .map(|(key, value)| (key.clone(), to_codec_lib_value(value))) + .collect(), + ), + LibValue::Data(value) => codec::LayerLibValue::Data(value.clone()), + LibValue::Date(value) => codec::LayerLibValue::Date(value.clone()), + LibValue::Uid(value) => codec::LayerLibValue::Uid(*value), + } +} + +fn from_codec_lib_value(value: codec::LayerLibValue) -> LibValue { + match value { + codec::LayerLibValue::String(value) => LibValue::String(value), + codec::LayerLibValue::Integer(value) => LibValue::Integer(value), + codec::LayerLibValue::UnsignedInteger(value) => LibValue::UnsignedInteger(value), + codec::LayerLibValue::Float(value) => LibValue::Float(value), + codec::LayerLibValue::Boolean(value) => LibValue::Boolean(value), + codec::LayerLibValue::Array(values) => { + LibValue::Array(values.into_iter().map(from_codec_lib_value).collect()) + } + codec::LayerLibValue::Dict(values) => LibValue::Dict( + values + .into_iter() + .map(|(key, value)| (key, from_codec_lib_value(value))) + .collect::>(), + ), + codec::LayerLibValue::Data(value) => LibValue::Data(value), + codec::LayerLibValue::Date(value) => LibValue::Date(value), + codec::LayerLibValue::Uid(value) => LibValue::Uid(value), + } +} + +fn parse_id(kind: &'static str, value: &str) -> Result +where + T: FromStr, +{ + value.parse().map_err(|_| PackedLayerError::InvalidId { + kind, + value: value.to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::sample_font; + + #[test] + fn complete_domain_layer_roundtrips_through_canonical_bytes() { + let font = sample_font(); + let glyph = font.glyph_by_name("A").unwrap(); + let layer = glyph + .layers() + .values() + .find(|layer| !layer.lib().is_empty()) + .unwrap(); + + let packed = pack_glyph_layer(layer).unwrap(); + let decoded = unpack_glyph_layer(packed.as_bytes()).unwrap(); + + assert_eq!(&decoded, layer.as_ref()); + assert_eq!(pack_glyph_layer(&decoded).unwrap(), packed); + } + + #[test] + fn adapter_rejects_codec_valid_but_domain_invalid_ids() { + let encoded = + codec::pack_layer(&codec::GlyphLayer::empty("wrong", "source_regular")).unwrap(); + + assert!(matches!( + unpack_glyph_layer(encoded.as_bytes()), + Err(PackedLayerError::InvalidId { + kind: "layer id", + .. + }) + )); + } + + #[test] + fn compatible_source_layers_preserve_shared_structure_and_distinct_values() { + let font = sample_font(); + let glyph = font.glyph_by_name("A").unwrap(); + let reference = glyph + .layers() + .values() + .find(|layer| !layer.lib().is_empty()) + .unwrap(); + let mut source = reference.clone_with_fresh_ids( + LayerId::from_raw("compatible"), + SourceId::from_raw("compatible"), + ); + source.set_width(reference.width() + 50.0); + source.contours_iter_mut().next().unwrap().points_mut()[0].set_position(125.0, 10.0); + assert!(reference + .interpolation_compatibility_with(&source) + .is_compatible()); + + let decoded_reference = + unpack_glyph_layer(pack_glyph_layer(reference).unwrap().as_bytes()).unwrap(); + let decoded_source = + unpack_glyph_layer(pack_glyph_layer(&source).unwrap().as_bytes()).unwrap(); + + assert!(decoded_reference + .interpolation_compatibility_with(&decoded_source) + .is_compatible()); + assert_ne!( + decoded_reference.contours_iter().next().unwrap().points()[0].x(), + decoded_source.contours_iter().next().unwrap().points()[0].x() + ); + } +} diff --git a/crates/shift-glyph-codec/SPECIFICATION.md b/crates/shift-glyph-codec/SPECIFICATION.md index 9134f373..13a160a7 100644 --- a/crates/shift-glyph-codec/SPECIFICATION.md +++ b/crates/shift-glyph-codec/SPECIFICATION.md @@ -1,6 +1,6 @@ # Shift glyph-codec specification -Status: normative for `shift.glyph-outline.v1` +Status: normative for `shift.glyph-outline.v1` and `shift.glyph-layer.v1` ## Compatibility and ownership @@ -12,26 +12,29 @@ implementations do not define the format by accident. derived, flattened preview/rendering outline. Components are already resolved, coordinates are drawing coordinates, and f64-to-f32 loss is permitted. It is never authoritative editable glyph state. -- `payloadKind = 0x02` is reserved for a future `shift.glyph-layer.v1` format. - That separate canonical format must preserve stable identities, authored point +- `shift.glyph-layer.v1` (`payloadKind = 0x02`, `formatVersion = 0x01`) is one + canonical authored glyph layer. It preserves stable identities, authored point semantics and ordering, contour closure, smooth flags, components and - transforms, anchors, metadata, and authored f64 precision. Outline bytes must - never be persisted or written back as a glyph layer. + transforms, anchors, guidelines, lib metadata, and authored f64 precision. -A future incompatible interpretation uses a new version scoped to its payload -kind. Unknown kinds, versions, and flag bits are errors. Codec implementations -own framing, validation, errors, and opaque packed values; they do not own font -semantics, transport, persistence, DOM, canvas, or SQLite behavior. +`PackedGlyphLayer` and `PackedGlyphOutline` are distinct opaque values. Outline +bytes must never be persisted or written back as a glyph layer. + +An incompatible interpretation uses a new version scoped to its payload kind. +Unknown kinds, versions, flag bits, point types, and lib tags are errors. Codec +implementations own framing, validation, errors, borrowed iteration, and opaque +packed values; they do not own font semantics, transport, persistence, DOM, +canvas, SQLite, or derived Slug/GPU layouts. ## Common frame -All multibyte values are little-endian. +All multibyte integers and IEEE-754 values are little-endian. ```text u8[4] magic ASCII "SHFT" -u8 payloadKind 0x01 = glyph-outline; 0x02 reserved for glyph-layer +u8 payloadKind 0x01 = glyph-outline; 0x02 = glyph-layer u8 formatVersion version scoped to payloadKind -u16 flags zero in outline v1 +u16 flags zero in both v1 formats ``` ## `shift.glyph-outline.v1` @@ -109,12 +112,214 @@ allocation or command iteration: - at most 6,000,000 coordinates; - at most 32 MiB total payload bytes. -These are implementation safety limits, not permission for future v1 encoders -to alter framing. +## `shift.glyph-layer.v1` + +One buffer represents one independently replaceable authored glyph layer. The +layer and source identities are included so a payload cannot be detached from +its semantic ownership accidentally. + +### Fixed header + +The fixed header is 72 bytes: + +| Offset | Type | Field | +| ---: | --- | --- | +| 0 | `u8[4]` | magic `SHFT` | +| 4 | `u8` | payload kind `0x02` | +| 5 | `u8` | format version `0x01` | +| 6 | `u16` | frame flags, zero | +| 8 | `u32` | exact total payload byte length | +| 12 | `u32` | distinct string count | +| 16 | `u32` | combined UTF-8 string byte length | +| 20 | `u32` | contour count | +| 24 | `u32` | total point count across contours | +| 28 | `u32` | component count | +| 32 | `u32` | anchor count | +| 36 | `u32` | guideline count | +| 40 | `u32` | encoded layer-lib byte length | +| 44 | `u32` | layer-ID string index | +| 48 | `u32` | source-ID string index | +| 52 | `u32` | layer flags; bit 0 means height is present | +| 56 | `f64` | width | +| 64 | `f64` | height, or positive-zero bits when absent | + +All count and byte-length fields describe the uncompressed v1 payload. V1 has no +compression flag. Compression belongs in an explicitly versioned outer storage +record or a future format revision. + +### Canonical string table + +The fixed header is followed by: + +```text +u32[stringCount + 1] offsets +u8[stringBytes] values +``` + +Offsets are relative to `values`, begin at zero, never decrease, and end at +`stringBytes`. Each slice is strict UTF-8. Values are unique, including empty +strings. + +Every string-bearing record stores a `u32` table index. Optional strings use +`0xffffffff` for absence. The table is in first-reference order over the wire: +layer ID, source ID, then each section and nested lib value in the order below. +A decoder rejects skipped first references and unreferenced table entries. This +makes equivalent inputs encode byte-for-byte identically without imposing a +lexical sort on identities or authored entity order. + +### Contours and points + +Contours preserve authored order. Each contour is: + +```text +u32 idString +u32 pointCount +u32 flags bit 0 = closed; all other bits zero +Point[pointCount] +``` + +Each 24-byte point is: + +```text +u32 idString +u8 pointType 0 = on-curve; 1 = off-curve; 2 = qcurve +u8 flags bit 0 = smooth; all other bits zero +u16 reserved zero +f64 x +f64 y +``` + +Empty open or closed contours are representable because authored structure is +preserved rather than inferred from drawing commands. Point IDs must be unique +within the payload; contour IDs must likewise be unique. + +### Components + +Components preserve authored order. Each 88-byte component is: + +```text +u32 idString +u32 baseGlyphIdString +u32 baseGlyphNameString +u32 reserved zero +f64 translateX +f64 translateY +f64 rotation +f64 scaleX +f64 scaleY +f64 skewX +f64 skewY +f64 transformCenterX +f64 transformCenterY +``` + +The decomposed authored transform is preserved directly. Components are not +flattened, and base glyph identity is not inferred from the editable name. +Component IDs must be unique within the payload. + +### Anchors + +Anchors preserve authored order. Each 24-byte anchor is: + +```text +u32 idString +u32 nameString or 0xffffffff +f64 x +f64 y +``` + +Anchor IDs must be unique within the payload. + +### Guidelines + +Guidelines preserve authored order. Each 40-byte guideline is: + +```text +u32 idString +u32 nameString or 0xffffffff +u32 colorString or 0xffffffff +u32 flags bit 0 = x present; bit 1 = y present; bit 2 = angle present +f64 x positive-zero bits when absent +f64 y positive-zero bits when absent +f64 angle positive-zero bits when absent +``` + +Guideline IDs must be unique within the payload. + +### Layer lib + +The final section has exactly the header's `layerLibBytes`. Its root is a +canonical dictionary: + +```text +u32 entryCount +Entry[entryCount] + +Entry := u32 keyString, Value value +Value := u8 tag, u8 reservedZero, u16 reservedZero, tag-specific payload +``` + +Dictionary keys are strictly increasing by their UTF-8 bytes. Duplicate keys +are therefore invalid. Arrays preserve authored order. + +| Tag | Value | Payload | +| ---: | --- | --- | +| 0 | string | `u32 stringIndex` | +| 1 | signed integer | `i64` | +| 2 | unsigned integer | `u64` | +| 3 | float | finite `f64` | +| 4 | boolean | `u8` 0 or 1, then three zero bytes | +| 5 | array | `u32 count`, then `Value[count]` | +| 6 | dictionary | root dictionary layout | +| 7 | binary data | `u32 byteLength`, then exact bytes | +| 8 | date | `u32 stringIndex` containing the preserved date text | +| 9 | archive UID | `u64` | + +### Numbers, identity, and order + +All authored numeric fields are finite IEEE-754 binary64 values and preserve +their exact bits, including negative zero when present. Optional absent numeric +slots use positive-zero bits so there is only one canonical encoding. + +Contour, point, component, anchor, and guideline identity is unique within its +entity kind in one payload. Font-wide identity and typed ID prefixes are domain +validation at the `shift-font` adapter boundary. The codec does not mint IDs or +infer missing structure. + +Authored order is preserved for contours, points, components, anchors, +guidelines, arrays, and all other sequence-valued fields. Dictionaries are maps, +not authored sequences, and use canonical UTF-8 key order. + +### Strict validation and limits + +A layer decoder validates the complete payload before exposing any view. It +rejects malformed framing, lengths, counts, offsets, UTF-8, references, +first-use order, duplicate strings or identities, unknown flags/types/tags, +non-zero reserved bytes, non-finite values, non-canonical absent slots, +non-canonical dictionary order, excessive nesting, truncation, and trailing +bytes. + +Repository v1 implementations apply these limits before unbounded allocation or +iteration: + +- 1,000,000 contours; +- 4,000,000 points; +- 1,000,000 components, anchors, and guidelines each; +- 4,000,000 distinct strings and 64 MiB combined UTF-8; +- 1,000,000 nested lib values and nesting depth 64; +- 256 MiB total payload bytes. + +The Rust borrowed view and TypeScript iterable view expose one layer's structure +incrementally. Consumers can build derived projections one contour/entity at a +time without constructing a complete font or retaining every editable layer. ## Golden vectors -Canonical binary vectors are checked in at -`fixtures/glyph-codec/outline-v1/`. Rust and TypeScript tests both consume those -same files and compare decoded commands and f32 coordinates. Re-encoding any -accepted canonical vector must reproduce every byte, including zero padding. +Canonical binary vectors are checked in at: + +- `fixtures/glyph-codec/outline-v1/` +- `fixtures/glyph-codec/layer-v1/` + +Rust and TypeScript tests consume the same files, compare semantic decode, and +require re-encoding to reproduce every byte. Any accepted canonical payload must +round-trip byte-for-byte. diff --git a/crates/shift-glyph-codec/examples/layer_benchmark.rs b/crates/shift-glyph-codec/examples/layer_benchmark.rs new file mode 100644 index 00000000..12817548 --- /dev/null +++ b/crates/shift-glyph-codec/examples/layer_benchmark.rs @@ -0,0 +1,136 @@ +use std::{collections::BTreeMap, hint::black_box, time::Instant}; + +use shift_glyph_codec::{ + decode_layer, pack_layer, GlyphLayer, LayerAnchor, LayerComponent, LayerContour, LayerPoint, + LayerPointType, LayerTransform, +}; + +fn outline_layer(name: &str, contour_count: usize, points_per_contour: usize) -> GlyphLayer { + let contours = (0..contour_count) + .map(|contour_index| LayerContour { + id: format!("contour_{name}_{contour_index}"), + closed: true, + points: (0..points_per_contour) + .map(|point_index| { + let value = (contour_index * points_per_contour + point_index) as f64; + LayerPoint { + id: format!("point_{name}_{contour_index}_{point_index}"), + point_type: match point_index % 3 { + 0 => LayerPointType::OnCurve, + 1 => LayerPointType::OffCurve, + _ => LayerPointType::QCurve, + }, + smooth: point_index % 5 == 0, + x: value * 0.75, + y: value * -0.375, + } + }) + .collect(), + }) + .collect(); + + GlyphLayer { + id: format!("layer_{name}"), + source_id: "source_regular".to_string(), + width: 1_000.0, + height: Some(1_000.0), + contours, + components: Vec::new(), + anchors: vec![LayerAnchor { + id: format!("anchor_{name}"), + name: Some("top".to_string()), + x: 500.0, + y: 800.0, + }], + guidelines: Vec::new(), + lib: BTreeMap::new(), + } +} + +fn composite_layer() -> GlyphLayer { + let mut layer = outline_layer("composite", 1, 4); + layer.components = (0..8) + .map(|index| LayerComponent { + id: format!("component_{index}"), + base_glyph_id: format!("glyph_base_{index}"), + base_glyph_name: format!("base.{index}"), + transform: LayerTransform { + translate_x: index as f64 * 40.0, + translate_y: index as f64 * 20.0, + rotation: index as f64, + ..LayerTransform::default() + }, + }) + .collect(); + layer +} + +fn run(name: &str, layer: &GlyphLayer, iterations: usize) { + let packed = pack_layer(layer).expect("representative layer should encode"); + for _ in 0..100 { + black_box(pack_layer(black_box(layer)).unwrap()); + black_box(decode_layer(black_box(packed.as_bytes())).unwrap()); + } + + let encode_start = Instant::now(); + for _ in 0..iterations { + black_box(pack_layer(black_box(layer)).unwrap()); + } + let encode_us = encode_start.elapsed().as_secs_f64() * 1_000_000.0 / iterations as f64; + + let decode_start = Instant::now(); + for _ in 0..iterations { + let view = decode_layer(black_box(packed.as_bytes())).unwrap(); + black_box(view.contours().flat_map(|contour| contour.points()).count()); + } + let decode_us = decode_start.elapsed().as_secs_f64() * 1_000_000.0 / iterations as f64; + + println!( + "case={name} payload_bytes={} contours={} points={} components={} encode_us={encode_us:.3} validate_iterate_us={decode_us:.3}", + packed.as_bytes().len(), + packed.view().contour_count(), + packed.view().point_count(), + packed.view().component_count(), + ); +} + +fn run_sparse_multi_source() { + const SOURCE_SLOTS: usize = 13; + const ITERATIONS: usize = 2_000; + let layers = (0..SOURCE_SLOTS) + .filter(|index| index % 4 == 0) + .map(|index| { + let mut layer = outline_layer(&format!("sparse_{index}"), 1, 3); + layer.source_id = format!("source_{index}"); + layer + }) + .collect::>(); + let payload_bytes = layers + .iter() + .map(|layer| pack_layer(layer).unwrap().as_bytes().len()) + .sum::(); + + let start = Instant::now(); + for _ in 0..ITERATIONS { + for layer in &layers { + black_box(pack_layer(black_box(layer)).unwrap()); + } + } + let per_set_us = start.elapsed().as_secs_f64() * 1_000_000.0 / ITERATIONS as f64; + println!( + "case=sparse-multi-source source_slots={SOURCE_SLOTS} present_layers={} payload_bytes={payload_bytes} encode_us_per_glyph_source_set={per_set_us:.3}", + layers.len(), + ); +} + +fn main() { + run("simple", &outline_layer("simple", 1, 24), 5_000); + run("composite", &composite_layer(), 5_000); + run_sparse_multi_source(); + run("cjk", &outline_layer("cjk", 24, 48), 500); + run( + "pathological", + &outline_layer("pathological", 1_000, 100), + 10, + ); +} diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs new file mode 100644 index 00000000..eb66e708 --- /dev/null +++ b/crates/shift-glyph-codec/src/layer.rs @@ -0,0 +1,1722 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::{error::Error, fmt}; + +const MAGIC: &[u8; 4] = b"SHFT"; +const LAYER_KIND: u8 = 0x02; +const LAYER_VERSION: u8 = 0x01; +const HEADER_LEN: usize = 72; +const NONE_STRING: u32 = u32::MAX; +const CONTOUR_LEN: usize = 12; +const POINT_LEN: usize = 24; +const COMPONENT_LEN: usize = 88; +const ANCHOR_LEN: usize = 24; +const GUIDELINE_LEN: usize = 40; + +/// Maximum contour count accepted by `shift.glyph-layer.v1`. +pub const MAX_LAYER_CONTOUR_COUNT: usize = 1_000_000; +/// Maximum point count accepted by `shift.glyph-layer.v1`. +pub const MAX_LAYER_POINT_COUNT: usize = 4_000_000; +/// Maximum count accepted for each other top-level layer entity kind. +pub const MAX_LAYER_ENTITY_COUNT: usize = 1_000_000; +/// Maximum number of distinct strings accepted by one layer payload. +pub const MAX_LAYER_STRING_COUNT: usize = 4_000_000; +/// Maximum combined UTF-8 string bytes accepted by one layer payload. +pub const MAX_LAYER_STRING_BYTES: usize = 64 * 1024 * 1024; +/// Maximum nested lib values accepted by one layer payload. +pub const MAX_LAYER_LIB_VALUES: usize = 1_000_000; +/// Maximum lib array/dictionary nesting depth. +pub const MAX_LAYER_LIB_DEPTH: usize = 64; +/// Maximum total encoded layer payload size. +pub const MAX_LAYER_PAYLOAD_BYTES: usize = 256 * 1024 * 1024; + +/// Codec-owned point semantics. Numeric discriminants are part of layer v1. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LayerPointType { + OnCurve, + OffCurve, + QCurve, +} + +impl LayerPointType { + fn byte(self) -> u8 { + match self { + Self::OnCurve => 0, + Self::OffCurve => 1, + Self::QCurve => 2, + } + } + + fn from_byte(value: u8, index: usize) -> Result { + match value { + 0 => Ok(Self::OnCurve), + 1 => Ok(Self::OffCurve), + 2 => Ok(Self::QCurve), + value => Err(LayerCodecError::UnknownPointType { index, value }), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerPoint { + pub id: String, + pub point_type: LayerPointType, + pub smooth: bool, + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerContour { + pub id: String, + pub closed: bool, + pub points: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayerTransform { + pub translate_x: f64, + pub translate_y: f64, + pub rotation: f64, + pub scale_x: f64, + pub scale_y: f64, + pub skew_x: f64, + pub skew_y: f64, + pub center_x: f64, + pub center_y: f64, +} + +impl Default for LayerTransform { + fn default() -> Self { + Self { + translate_x: 0.0, + translate_y: 0.0, + rotation: 0.0, + scale_x: 1.0, + scale_y: 1.0, + skew_x: 0.0, + skew_y: 0.0, + center_x: 0.0, + center_y: 0.0, + } + } +} + +impl LayerTransform { + fn values(self) -> [f64; 9] { + [ + self.translate_x, + self.translate_y, + self.rotation, + self.scale_x, + self.scale_y, + self.skew_x, + self.skew_y, + self.center_x, + self.center_y, + ] + } + + fn from_values(values: [f64; 9]) -> Self { + Self { + translate_x: values[0], + translate_y: values[1], + rotation: values[2], + scale_x: values[3], + scale_y: values[4], + skew_x: values[5], + skew_y: values[6], + center_x: values[7], + center_y: values[8], + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerComponent { + pub id: String, + pub base_glyph_id: String, + pub base_glyph_name: String, + pub transform: LayerTransform, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerAnchor { + pub id: String, + pub name: Option, + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct LayerGuideline { + pub id: String, + pub x: Option, + pub y: Option, + pub angle: Option, + pub name: Option, + pub color: Option, +} + +/// Lossless values supported by authored layer lib dictionaries. +#[derive(Clone, Debug, PartialEq)] +pub enum LayerLibValue { + String(String), + Integer(i64), + UnsignedInteger(u64), + Float(f64), + Boolean(bool), + Array(Vec), + Dict(BTreeMap), + Data(Vec), + Date(String), + Uid(u64), +} + +/// Codec-owned complete authored layer input/output. +#[derive(Clone, Debug, PartialEq)] +pub struct GlyphLayer { + pub id: String, + pub source_id: String, + pub width: f64, + pub height: Option, + pub contours: Vec, + pub components: Vec, + pub anchors: Vec, + pub guidelines: Vec, + pub lib: BTreeMap, +} + +impl GlyphLayer { + pub fn empty(id: impl Into, source_id: impl Into) -> Self { + Self { + id: id.into(), + source_id: source_id.into(), + width: 0.0, + height: None, + contours: Vec::new(), + components: Vec::new(), + anchors: Vec::new(), + guidelines: Vec::new(), + lib: BTreeMap::new(), + } + } +} + +/// Strict authored-layer codec failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum LayerCodecError { + HeaderTruncated { + actual: usize, + }, + WrongMagic, + UnsupportedPayloadKind(u8), + UnsupportedVersion(u8), + UnknownFlags(u16), + UnknownLayerFlags(u32), + UnknownRecordFlags { + record: &'static str, + index: usize, + flags: u32, + }, + NonZeroReserved { + record: &'static str, + index: usize, + }, + UnknownPointType { + index: usize, + value: u8, + }, + UnknownLibTag { + offset: usize, + value: u8, + }, + InvalidBoolean { + offset: usize, + value: u8, + }, + InvalidUtf8 { + index: usize, + }, + InvalidStringOffsets { + index: usize, + }, + DuplicateString { + index: usize, + }, + StringReferenceOutOfRange { + index: u32, + }, + NonCanonicalStringReference { + expected_at_most: u32, + actual: u32, + }, + UnreferencedStrings { + first: u32, + }, + DuplicateIdentity { + kind: &'static str, + index: usize, + }, + NonCanonicalMapOrder { + offset: usize, + }, + NonFiniteNumber { + field: &'static str, + index: usize, + }, + NonCanonicalAbsentNumber { + field: &'static str, + index: usize, + }, + PointCountMismatch { + expected: usize, + actual: usize, + }, + LibLengthMismatch { + expected: usize, + actual: usize, + }, + NestingLimitExceeded { + limit: usize, + }, + Truncated { + offset: usize, + needed: usize, + available: usize, + }, + LengthOverflow, + LengthMismatch { + expected: usize, + actual: usize, + }, + LimitExceeded { + field: &'static str, + limit: usize, + actual: usize, + }, +} + +impl fmt::Display for LayerCodecError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderTruncated { actual } => write!(formatter, "glyph-layer header is truncated: {actual} of {HEADER_LEN} bytes"), + Self::WrongMagic => formatter.write_str("wrong glyph-codec magic"), + Self::UnsupportedPayloadKind(kind) => write!(formatter, "unsupported glyph-codec payload kind {kind:#04x}"), + Self::UnsupportedVersion(version) => write!(formatter, "unsupported glyph-layer version {version}"), + Self::UnknownFlags(flags) => write!(formatter, "unknown glyph-layer frame flags {flags:#06x}"), + Self::UnknownLayerFlags(flags) => write!(formatter, "unknown glyph-layer flags {flags:#010x}"), + Self::UnknownRecordFlags { record, index, flags } => write!(formatter, "unknown {record} flags {flags:#010x} at index {index}"), + Self::NonZeroReserved { record, index } => write!(formatter, "non-zero reserved {record} bytes at index {index}"), + Self::UnknownPointType { index, value } => write!(formatter, "unknown point type {value:#04x} at index {index}"), + Self::UnknownLibTag { offset, value } => write!(formatter, "unknown lib value tag {value:#04x} at byte {offset}"), + Self::InvalidBoolean { offset, value } => write!(formatter, "invalid boolean {value} at byte {offset}"), + Self::InvalidUtf8 { index } => write!(formatter, "invalid UTF-8 in string {index}"), + Self::InvalidStringOffsets { index } => write!(formatter, "non-canonical string offsets at index {index}"), + Self::DuplicateString { index } => write!(formatter, "duplicate string-table value at index {index}"), + Self::StringReferenceOutOfRange { index } => write!(formatter, "string reference {index} is out of range"), + Self::NonCanonicalStringReference { expected_at_most, actual } => write!(formatter, "string reference {actual} is out of canonical first-use order; expected at most {expected_at_most}"), + Self::UnreferencedStrings { first } => write!(formatter, "string table has unreferenced entries beginning at index {first}"), + Self::DuplicateIdentity { kind, index } => write!(formatter, "duplicate {kind} identity at index {index}"), + Self::NonCanonicalMapOrder { offset } => write!(formatter, "dictionary keys are not in canonical UTF-8 order at byte {offset}"), + Self::NonFiniteNumber { field, index } => write!(formatter, "non-finite {field} value at index {index}"), + Self::NonCanonicalAbsentNumber { field, index } => write!(formatter, "absent {field} value at index {index} is not canonical positive zero"), + Self::PointCountMismatch { expected, actual } => write!(formatter, "contours contain {actual} points, header declares {expected}"), + Self::LibLengthMismatch { expected, actual } => write!(formatter, "layer lib length mismatch: expected {expected} bytes, consumed {actual}"), + Self::NestingLimitExceeded { limit } => write!(formatter, "layer lib nesting exceeds implementation limit {limit}"), + Self::Truncated { offset, needed, available } => write!(formatter, "glyph-layer body is truncated at byte {offset}: need {needed} bytes, have {available}"), + Self::LengthOverflow => formatter.write_str("glyph-layer payload length arithmetic overflowed"), + Self::LengthMismatch { expected, actual } => write!(formatter, "glyph-layer payload length mismatch: expected {expected} bytes, got {actual}"), + Self::LimitExceeded { field, limit, actual } => write!(formatter, "glyph-layer {field} exceeds implementation limit {limit}: got {actual}"), + } + } +} + +impl Error for LayerCodecError {} + +/// Owned canonical `shift.glyph-layer.v1` bytes. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackedGlyphLayer(Vec); + +impl PackedGlyphLayer { + pub fn from_bytes(bytes: Vec) -> Result { + decode_layer(&bytes)?; + Ok(Self(bytes)) + } + + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + pub fn into_bytes(self) -> Vec { + self.0 + } + + pub fn view(&self) -> GlyphLayerView<'_> { + decode_layer(&self.0).expect("PackedGlyphLayer invariant violated") + } + + pub fn unpack(&self) -> GlyphLayer { + self.view().unpack() + } +} + +#[derive(Clone, Copy, Debug)] +struct Strings<'a> { + bytes: &'a [u8], + count: usize, + offsets_offset: usize, + values_offset: usize, +} + +impl<'a> Strings<'a> { + fn get(self, index: u32) -> &'a str { + let index = index as usize; + debug_assert!(index < self.count); + let start = read_u32(self.bytes, self.offsets_offset + index * 4) as usize; + let end = read_u32(self.bytes, self.offsets_offset + (index + 1) * 4) as usize; + std::str::from_utf8(&self.bytes[self.values_offset + start..self.values_offset + end]) + .expect("validated UTF-8") + } +} + +/// Validated allocation-free access to an authored layer payload. +#[derive(Clone, Copy, Debug)] +pub struct GlyphLayerView<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + id_index: u32, + source_id_index: u32, + width: f64, + height: Option, + contour_count: usize, + point_count: usize, + component_count: usize, + anchor_count: usize, + guideline_count: usize, + contours_offset: usize, + components_offset: usize, + anchors_offset: usize, + guidelines_offset: usize, + lib_offset: usize, + lib_end: usize, +} + +impl<'a> GlyphLayerView<'a> { + pub fn id(self) -> &'a str { + self.strings.get(self.id_index) + } + pub fn source_id(self) -> &'a str { + self.strings.get(self.source_id_index) + } + pub fn width(self) -> f64 { + self.width + } + pub fn height(self) -> Option { + self.height + } + pub fn contour_count(self) -> usize { + self.contour_count + } + pub fn point_count(self) -> usize { + self.point_count + } + pub fn component_count(self) -> usize { + self.component_count + } + pub fn anchor_count(self) -> usize { + self.anchor_count + } + pub fn guideline_count(self) -> usize { + self.guideline_count + } + + /// Streams contours; each contour streams its points directly from bytes. + pub fn contours(self) -> LayerContourIter<'a> { + LayerContourIter { + bytes: self.bytes, + strings: self.strings, + offset: self.contours_offset, + remaining: self.contour_count, + } + } + + pub fn components(self) -> LayerComponentIter<'a> { + LayerComponentIter { + bytes: self.bytes, + strings: self.strings, + offset: self.components_offset, + remaining: self.component_count, + index: 0, + } + } + + pub fn anchors(self) -> LayerAnchorIter<'a> { + LayerAnchorIter { + bytes: self.bytes, + strings: self.strings, + offset: self.anchors_offset, + remaining: self.anchor_count, + } + } + + pub fn guidelines(self) -> LayerGuidelineIter<'a> { + LayerGuidelineIter { + bytes: self.bytes, + strings: self.strings, + offset: self.guidelines_offset, + remaining: self.guideline_count, + } + } + + pub fn lib(self) -> BTreeMap { + let mut cursor = Cursor::with_range(self.bytes, self.lib_offset, self.lib_end); + let mut state = DecodeState::for_validated(self.strings); + decode_map(&mut cursor, &mut state, 0).expect("validated layer lib") + } + + /// Materializes exactly one editable layer, never a complete font. + pub fn unpack(self) -> GlyphLayer { + GlyphLayer { + id: self.id().to_owned(), + source_id: self.source_id().to_owned(), + width: self.width, + height: self.height, + contours: self.contours().map(LayerContourView::unpack).collect(), + components: self.components().map(LayerComponentView::unpack).collect(), + anchors: self.anchors().map(LayerAnchorView::unpack).collect(), + guidelines: self.guidelines().map(LayerGuidelineView::unpack).collect(), + lib: self.lib(), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct LayerContourView<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + id_index: u32, + closed: bool, + point_count: usize, + points_offset: usize, +} + +impl<'a> LayerContourView<'a> { + pub fn id(self) -> &'a str { + self.strings.get(self.id_index) + } + pub fn closed(self) -> bool { + self.closed + } + pub fn point_count(self) -> usize { + self.point_count + } + pub fn points(self) -> LayerPointIter<'a> { + LayerPointIter { + bytes: self.bytes, + strings: self.strings, + offset: self.points_offset, + remaining: self.point_count, + } + } + fn unpack(self) -> LayerContour { + LayerContour { + id: self.id().to_owned(), + closed: self.closed, + points: self.points().map(LayerPointView::unpack).collect(), + } + } +} + +pub struct LayerContourIter<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + offset: usize, + remaining: usize, +} + +impl<'a> Iterator for LayerContourIter<'a> { + type Item = LayerContourView<'a>; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let id_index = read_u32(self.bytes, self.offset); + let point_count = read_u32(self.bytes, self.offset + 4) as usize; + let closed = read_u32(self.bytes, self.offset + 8) & 1 != 0; + let points_offset = self.offset + CONTOUR_LEN; + self.offset = points_offset + point_count * POINT_LEN; + self.remaining -= 1; + Some(LayerContourView { + bytes: self.bytes, + strings: self.strings, + id_index, + closed, + point_count, + points_offset, + }) + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for LayerContourIter<'_> {} + +#[derive(Clone, Copy, Debug)] +pub struct LayerPointView<'a> { + id: &'a str, + point_type: LayerPointType, + smooth: bool, + x: f64, + y: f64, +} +impl<'a> LayerPointView<'a> { + pub fn id(self) -> &'a str { + self.id + } + pub fn point_type(self) -> LayerPointType { + self.point_type + } + pub fn smooth(self) -> bool { + self.smooth + } + pub fn x(self) -> f64 { + self.x + } + pub fn y(self) -> f64 { + self.y + } + fn unpack(self) -> LayerPoint { + LayerPoint { + id: self.id.to_owned(), + point_type: self.point_type, + smooth: self.smooth, + x: self.x, + y: self.y, + } + } +} + +pub struct LayerPointIter<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + offset: usize, + remaining: usize, +} +impl<'a> Iterator for LayerPointIter<'a> { + type Item = LayerPointView<'a>; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let index = read_u32(self.bytes, self.offset); + let point_type = match self.bytes[self.offset + 4] { + 0 => LayerPointType::OnCurve, + 1 => LayerPointType::OffCurve, + 2 => LayerPointType::QCurve, + _ => unreachable!("validated point type"), + }; + let smooth = self.bytes[self.offset + 5] & 1 != 0; + let x = read_f64(self.bytes, self.offset + 8); + let y = read_f64(self.bytes, self.offset + 16); + self.offset += POINT_LEN; + self.remaining -= 1; + Some(LayerPointView { + id: self.strings.get(index), + point_type, + smooth, + x, + y, + }) + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for LayerPointIter<'_> {} + +#[derive(Clone, Copy, Debug)] +pub struct LayerComponentView<'a> { + id: &'a str, + base_glyph_id: &'a str, + base_glyph_name: &'a str, + transform: LayerTransform, +} +impl<'a> LayerComponentView<'a> { + pub fn id(self) -> &'a str { + self.id + } + pub fn base_glyph_id(self) -> &'a str { + self.base_glyph_id + } + pub fn base_glyph_name(self) -> &'a str { + self.base_glyph_name + } + pub fn transform(self) -> LayerTransform { + self.transform + } + fn unpack(self) -> LayerComponent { + LayerComponent { + id: self.id.to_owned(), + base_glyph_id: self.base_glyph_id.to_owned(), + base_glyph_name: self.base_glyph_name.to_owned(), + transform: self.transform, + } + } +} +pub struct LayerComponentIter<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + offset: usize, + remaining: usize, + index: usize, +} +impl<'a> Iterator for LayerComponentIter<'a> { + type Item = LayerComponentView<'a>; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let id = self.strings.get(read_u32(self.bytes, self.offset)); + let base_glyph_id = self.strings.get(read_u32(self.bytes, self.offset + 4)); + let base_glyph_name = self.strings.get(read_u32(self.bytes, self.offset + 8)); + let mut values = [0.0; 9]; + for (value_index, value) in values.iter_mut().enumerate() { + *value = read_f64(self.bytes, self.offset + 16 + value_index * 8); + } + self.offset += COMPONENT_LEN; + self.remaining -= 1; + self.index += 1; + Some(LayerComponentView { + id, + base_glyph_id, + base_glyph_name, + transform: LayerTransform::from_values(values), + }) + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for LayerComponentIter<'_> {} + +#[derive(Clone, Copy, Debug)] +pub struct LayerAnchorView<'a> { + id: &'a str, + name: Option<&'a str>, + x: f64, + y: f64, +} +impl<'a> LayerAnchorView<'a> { + pub fn id(self) -> &'a str { + self.id + } + pub fn name(self) -> Option<&'a str> { + self.name + } + pub fn x(self) -> f64 { + self.x + } + pub fn y(self) -> f64 { + self.y + } + fn unpack(self) -> LayerAnchor { + LayerAnchor { + id: self.id.to_owned(), + name: self.name.map(str::to_owned), + x: self.x, + y: self.y, + } + } +} +pub struct LayerAnchorIter<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + offset: usize, + remaining: usize, +} +impl<'a> Iterator for LayerAnchorIter<'a> { + type Item = LayerAnchorView<'a>; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let id = self.strings.get(read_u32(self.bytes, self.offset)); + let name = optional_string(self.strings, read_u32(self.bytes, self.offset + 4)); + let x = read_f64(self.bytes, self.offset + 8); + let y = read_f64(self.bytes, self.offset + 16); + self.offset += ANCHOR_LEN; + self.remaining -= 1; + Some(LayerAnchorView { id, name, x, y }) + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for LayerAnchorIter<'_> {} + +#[derive(Clone, Copy, Debug)] +pub struct LayerGuidelineView<'a> { + id: &'a str, + x: Option, + y: Option, + angle: Option, + name: Option<&'a str>, + color: Option<&'a str>, +} +impl<'a> LayerGuidelineView<'a> { + pub fn id(self) -> &'a str { + self.id + } + pub fn x(self) -> Option { + self.x + } + pub fn y(self) -> Option { + self.y + } + pub fn angle(self) -> Option { + self.angle + } + pub fn name(self) -> Option<&'a str> { + self.name + } + pub fn color(self) -> Option<&'a str> { + self.color + } + fn unpack(self) -> LayerGuideline { + LayerGuideline { + id: self.id.to_owned(), + x: self.x, + y: self.y, + angle: self.angle, + name: self.name.map(str::to_owned), + color: self.color.map(str::to_owned), + } + } +} +pub struct LayerGuidelineIter<'a> { + bytes: &'a [u8], + strings: Strings<'a>, + offset: usize, + remaining: usize, +} +impl<'a> Iterator for LayerGuidelineIter<'a> { + type Item = LayerGuidelineView<'a>; + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + let id = self.strings.get(read_u32(self.bytes, self.offset)); + let name = optional_string(self.strings, read_u32(self.bytes, self.offset + 4)); + let color = optional_string(self.strings, read_u32(self.bytes, self.offset + 8)); + let flags = read_u32(self.bytes, self.offset + 12); + let raw = [ + read_f64(self.bytes, self.offset + 16), + read_f64(self.bytes, self.offset + 24), + read_f64(self.bytes, self.offset + 32), + ]; + self.offset += GUIDELINE_LEN; + self.remaining -= 1; + Some(LayerGuidelineView { + id, + name, + color, + x: (flags & 1 != 0).then_some(raw[0]), + y: (flags & 2 != 0).then_some(raw[1]), + angle: (flags & 4 != 0).then_some(raw[2]), + }) + } + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} +impl ExactSizeIterator for LayerGuidelineIter<'_> {} + +fn optional_string<'a>(strings: Strings<'a>, index: u32) -> Option<&'a str> { + (index != NONE_STRING).then(|| strings.get(index)) +} + +/// Canonically encodes a complete authored layer. +pub fn pack_layer(layer: &GlyphLayer) -> Result { + check_limit( + "contour count", + layer.contours.len(), + MAX_LAYER_CONTOUR_COUNT, + )?; + check_limit( + "component count", + layer.components.len(), + MAX_LAYER_ENTITY_COUNT, + )?; + check_limit("anchor count", layer.anchors.len(), MAX_LAYER_ENTITY_COUNT)?; + check_limit( + "guideline count", + layer.guidelines.len(), + MAX_LAYER_ENTITY_COUNT, + )?; + let point_count = layer.contours.iter().try_fold(0usize, |total, contour| { + total + .checked_add(contour.points.len()) + .ok_or(LayerCodecError::LengthOverflow) + })?; + check_limit("point count", point_count, MAX_LAYER_POINT_COUNT)?; + + let mut strings = StringPool::default(); + strings.intern(&layer.id)?; + strings.intern(&layer.source_id)?; + for contour in &layer.contours { + strings.intern(&contour.id)?; + for point in &contour.points { + strings.intern(&point.id)?; + } + } + for component in &layer.components { + strings.intern(&component.id)?; + strings.intern(&component.base_glyph_id)?; + strings.intern(&component.base_glyph_name)?; + } + for anchor in &layer.anchors { + strings.intern(&anchor.id)?; + if let Some(name) = &anchor.name { + strings.intern(name)?; + } + } + for guideline in &layer.guidelines { + strings.intern(&guideline.id)?; + if let Some(name) = &guideline.name { + strings.intern(name)?; + } + if let Some(color) = &guideline.color { + strings.intern(color)?; + } + } + gather_map_strings(&layer.lib, &mut strings, 0, &mut 0)?; + + let mut bytes = vec![0; HEADER_LEN]; + let mut running_string_bytes = 0usize; + push_u32(&mut bytes, 0)?; + for value in &strings.values { + running_string_bytes = running_string_bytes + .checked_add(value.len()) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("string bytes", running_string_bytes, MAX_LAYER_STRING_BYTES)?; + push_u32(&mut bytes, running_string_bytes)?; + } + for value in &strings.values { + bytes.extend_from_slice(value.as_bytes()); + } + + for contour in &layer.contours { + push_u32(&mut bytes, strings.index(&contour.id))?; + push_u32(&mut bytes, contour.points.len())?; + push_u32(&mut bytes, u32::from(contour.closed))?; + for point in &contour.points { + push_u32(&mut bytes, strings.index(&point.id))?; + bytes.push(point.point_type.byte()); + bytes.push(u8::from(point.smooth)); + bytes.extend_from_slice(&0u16.to_le_bytes()); + bytes.extend_from_slice(&point.x.to_le_bytes()); + bytes.extend_from_slice(&point.y.to_le_bytes()); + } + } + for component in &layer.components { + push_u32(&mut bytes, strings.index(&component.id))?; + push_u32(&mut bytes, strings.index(&component.base_glyph_id))?; + push_u32(&mut bytes, strings.index(&component.base_glyph_name))?; + push_u32(&mut bytes, 0)?; + for value in component.transform.values() { + bytes.extend_from_slice(&value.to_le_bytes()); + } + } + for anchor in &layer.anchors { + push_u32(&mut bytes, strings.index(&anchor.id))?; + push_u32( + &mut bytes, + anchor + .name + .as_ref() + .map_or(NONE_STRING, |value| strings.index(value)), + )?; + bytes.extend_from_slice(&anchor.x.to_le_bytes()); + bytes.extend_from_slice(&anchor.y.to_le_bytes()); + } + for guideline in &layer.guidelines { + push_u32(&mut bytes, strings.index(&guideline.id))?; + push_u32( + &mut bytes, + guideline + .name + .as_ref() + .map_or(NONE_STRING, |value| strings.index(value)), + )?; + push_u32( + &mut bytes, + guideline + .color + .as_ref() + .map_or(NONE_STRING, |value| strings.index(value)), + )?; + let flags = u32::from(guideline.x.is_some()) + | (u32::from(guideline.y.is_some()) << 1) + | (u32::from(guideline.angle.is_some()) << 2); + push_u32(&mut bytes, flags)?; + for value in [guideline.x, guideline.y, guideline.angle] { + bytes.extend_from_slice(&value.unwrap_or(0.0).to_le_bytes()); + } + } + let lib_offset = bytes.len(); + encode_map(&mut bytes, &layer.lib, &strings, 0, &mut 0)?; + let lib_bytes = bytes + .len() + .checked_sub(lib_offset) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("payload byte length", bytes.len(), MAX_LAYER_PAYLOAD_BYTES)?; + + bytes[..4].copy_from_slice(MAGIC); + bytes[4] = LAYER_KIND; + bytes[5] = LAYER_VERSION; + write_u16(&mut bytes, 6, 0); + let payload_len = bytes.len(); + write_u32(&mut bytes, 8, payload_len)?; + write_u32(&mut bytes, 12, strings.values.len())?; + write_u32(&mut bytes, 16, running_string_bytes)?; + write_u32(&mut bytes, 20, layer.contours.len())?; + write_u32(&mut bytes, 24, point_count)?; + write_u32(&mut bytes, 28, layer.components.len())?; + write_u32(&mut bytes, 32, layer.anchors.len())?; + write_u32(&mut bytes, 36, layer.guidelines.len())?; + write_u32(&mut bytes, 40, lib_bytes)?; + write_u32(&mut bytes, 44, strings.index(&layer.id))?; + write_u32(&mut bytes, 48, strings.index(&layer.source_id))?; + write_u32(&mut bytes, 52, u32::from(layer.height.is_some()))?; + bytes[56..64].copy_from_slice(&layer.width.to_le_bytes()); + bytes[64..72].copy_from_slice(&layer.height.unwrap_or(0.0).to_le_bytes()); + + decode_layer(&bytes)?; + Ok(PackedGlyphLayer(bytes)) +} + +/// Strictly validates one canonical authored layer and returns borrowed streams. +pub fn decode_layer(bytes: &[u8]) -> Result, LayerCodecError> { + if bytes.len() < HEADER_LEN { + return Err(LayerCodecError::HeaderTruncated { + actual: bytes.len(), + }); + } + check_limit("payload byte length", bytes.len(), MAX_LAYER_PAYLOAD_BYTES)?; + if &bytes[..4] != MAGIC { + return Err(LayerCodecError::WrongMagic); + } + if bytes[4] != LAYER_KIND { + return Err(LayerCodecError::UnsupportedPayloadKind(bytes[4])); + } + if bytes[5] != LAYER_VERSION { + return Err(LayerCodecError::UnsupportedVersion(bytes[5])); + } + let frame_flags = read_u16(bytes, 6); + if frame_flags != 0 { + return Err(LayerCodecError::UnknownFlags(frame_flags)); + } + let declared_len = read_u32(bytes, 8) as usize; + if declared_len != bytes.len() { + return Err(LayerCodecError::LengthMismatch { + expected: declared_len, + actual: bytes.len(), + }); + } + + let string_count = read_u32(bytes, 12) as usize; + let string_bytes = read_u32(bytes, 16) as usize; + let contour_count = read_u32(bytes, 20) as usize; + let point_count = read_u32(bytes, 24) as usize; + let component_count = read_u32(bytes, 28) as usize; + let anchor_count = read_u32(bytes, 32) as usize; + let guideline_count = read_u32(bytes, 36) as usize; + let lib_bytes = read_u32(bytes, 40) as usize; + let id_index = read_u32(bytes, 44); + let source_id_index = read_u32(bytes, 48); + let layer_flags = read_u32(bytes, 52); + if layer_flags & !1 != 0 { + return Err(LayerCodecError::UnknownLayerFlags(layer_flags)); + } + let width = read_f64(bytes, 56); + validate_finite(width, "width", 0)?; + let raw_height = read_f64(bytes, 64); + let height = if layer_flags & 1 != 0 { + validate_finite(raw_height, "height", 0)?; + Some(raw_height) + } else { + validate_absent(raw_height, "height", 0)?; + None + }; + + check_limit("string count", string_count, MAX_LAYER_STRING_COUNT)?; + check_limit("string bytes", string_bytes, MAX_LAYER_STRING_BYTES)?; + check_limit("contour count", contour_count, MAX_LAYER_CONTOUR_COUNT)?; + check_limit("point count", point_count, MAX_LAYER_POINT_COUNT)?; + check_limit("component count", component_count, MAX_LAYER_ENTITY_COUNT)?; + check_limit("anchor count", anchor_count, MAX_LAYER_ENTITY_COUNT)?; + check_limit("guideline count", guideline_count, MAX_LAYER_ENTITY_COUNT)?; + + let offsets_len = string_count + .checked_add(1) + .and_then(|count| count.checked_mul(4)) + .ok_or(LayerCodecError::LengthOverflow)?; + let values_offset = HEADER_LEN + .checked_add(offsets_len) + .ok_or(LayerCodecError::LengthOverflow)?; + let strings_end = values_offset + .checked_add(string_bytes) + .ok_or(LayerCodecError::LengthOverflow)?; + require_range( + bytes, + HEADER_LEN, + offsets_len + .checked_add(string_bytes) + .ok_or(LayerCodecError::LengthOverflow)?, + )?; + let strings = Strings { + bytes, + count: string_count, + offsets_offset: HEADER_LEN, + values_offset, + }; + validate_strings(strings, string_bytes)?; + + let mut state = DecodeState::new(strings); + state.reference(id_index)?; + state.reference(source_id_index)?; + let mut cursor = Cursor::with_range(bytes, strings_end, bytes.len()); + let contours_offset = cursor.offset; + let mut actual_points = 0usize; + for contour_index in 0..contour_count { + let contour_id = state.required_string(cursor.u32()?)?; + state.unique_identity("contour", contour_index, contour_id)?; + let count = cursor.u32()? as usize; + actual_points = actual_points + .checked_add(count) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("point count", actual_points, MAX_LAYER_POINT_COUNT)?; + let flags = cursor.u32()?; + if flags & !1 != 0 { + return Err(LayerCodecError::UnknownRecordFlags { + record: "contour", + index: contour_index, + flags, + }); + } + for _ in 0..count { + let point_index = state.point_index; + let point_id = state.required_string(cursor.u32()?)?; + state.unique_identity("point", point_index, point_id)?; + let point_type = cursor.u8()?; + LayerPointType::from_byte(point_type, point_index)?; + let flags = cursor.u8()?; + if flags & !1 != 0 { + return Err(LayerCodecError::UnknownRecordFlags { + record: "point", + index: point_index, + flags: u32::from(flags), + }); + } + if cursor.u16()? != 0 { + return Err(LayerCodecError::NonZeroReserved { + record: "point", + index: point_index, + }); + } + validate_finite(cursor.f64()?, "point coordinate", point_index * 2)?; + validate_finite(cursor.f64()?, "point coordinate", point_index * 2 + 1)?; + state.point_index += 1; + } + } + if actual_points != point_count { + return Err(LayerCodecError::PointCountMismatch { + expected: point_count, + actual: actual_points, + }); + } + + let components_offset = cursor.offset; + for index in 0..component_count { + let id = state.required_string(cursor.u32()?)?; + state.unique_identity("component", index, id)?; + state.required_string(cursor.u32()?)?; + state.required_string(cursor.u32()?)?; + if cursor.u32()? != 0 { + return Err(LayerCodecError::NonZeroReserved { + record: "component", + index, + }); + } + for value_index in 0..9 { + validate_finite( + cursor.f64()?, + "component transform", + index * 9 + value_index, + )?; + } + } + let anchors_offset = cursor.offset; + for index in 0..anchor_count { + let id = state.required_string(cursor.u32()?)?; + state.unique_identity("anchor", index, id)?; + state.optional_string(cursor.u32()?)?; + validate_finite(cursor.f64()?, "anchor coordinate", index * 2)?; + validate_finite(cursor.f64()?, "anchor coordinate", index * 2 + 1)?; + } + let guidelines_offset = cursor.offset; + for index in 0..guideline_count { + let id = state.required_string(cursor.u32()?)?; + state.unique_identity("guideline", index, id)?; + state.optional_string(cursor.u32()?)?; + state.optional_string(cursor.u32()?)?; + let flags = cursor.u32()?; + if flags & !7 != 0 { + return Err(LayerCodecError::UnknownRecordFlags { + record: "guideline", + index, + flags, + }); + } + for (value_index, bit) in [1, 2, 4].into_iter().enumerate() { + let value = cursor.f64()?; + if flags & bit != 0 { + validate_finite(value, "guideline number", index * 3 + value_index)?; + } else { + validate_absent(value, "guideline number", index * 3 + value_index)?; + } + } + } + let lib_offset = cursor.offset; + let lib_end = lib_offset + .checked_add(lib_bytes) + .ok_or(LayerCodecError::LengthOverflow)?; + if lib_end > bytes.len() { + return Err(LayerCodecError::Truncated { + offset: lib_offset, + needed: lib_bytes, + available: bytes.len().saturating_sub(lib_offset), + }); + } + cursor.end = lib_end; + decode_map(&mut cursor, &mut state, 0)?; + let consumed_lib = cursor.offset - lib_offset; + if consumed_lib != lib_bytes { + return Err(LayerCodecError::LibLengthMismatch { + expected: lib_bytes, + actual: consumed_lib, + }); + } + if lib_end != bytes.len() { + return Err(LayerCodecError::LengthMismatch { + expected: lib_end, + actual: bytes.len(), + }); + } + if state.next_string != string_count as u32 { + return Err(LayerCodecError::UnreferencedStrings { + first: state.next_string, + }); + } + + Ok(GlyphLayerView { + bytes, + strings, + id_index, + source_id_index, + width, + height, + contour_count, + point_count, + component_count, + anchor_count, + guideline_count, + contours_offset, + components_offset, + anchors_offset, + guidelines_offset, + lib_offset, + lib_end, + }) +} + +#[derive(Default)] +struct StringPool { + values: Vec, + indexes: HashMap, +} +impl StringPool { + fn intern(&mut self, value: &str) -> Result { + if let Some(index) = self.indexes.get(value) { + return Ok(*index); + } + check_limit( + "string count", + self.values.len() + 1, + MAX_LAYER_STRING_COUNT, + )?; + let index = + u32::try_from(self.values.len()).map_err(|_| LayerCodecError::LengthOverflow)?; + self.values.push(value.to_owned()); + self.indexes.insert(value.to_owned(), index); + Ok(index) + } + fn index(&self, value: &str) -> u32 { + *self + .indexes + .get(value) + .expect("strings gathered before encoding") + } +} + +struct DecodeState<'a> { + strings: Strings<'a>, + next_string: u32, + identities: HashMap<&'static str, HashSet<&'a str>>, + point_index: usize, + lib_values: usize, + validate_references: bool, +} +impl<'a> DecodeState<'a> { + fn new(strings: Strings<'a>) -> Self { + Self { + strings, + next_string: 0, + identities: HashMap::new(), + point_index: 0, + lib_values: 0, + validate_references: true, + } + } + fn for_validated(strings: Strings<'a>) -> Self { + Self { + validate_references: false, + ..Self::new(strings) + } + } + fn reference(&mut self, index: u32) -> Result<(), LayerCodecError> { + if index as usize >= self.strings.count { + return Err(LayerCodecError::StringReferenceOutOfRange { index }); + } + if self.validate_references { + if index > self.next_string { + return Err(LayerCodecError::NonCanonicalStringReference { + expected_at_most: self.next_string, + actual: index, + }); + } + if index == self.next_string { + self.next_string += 1; + } + } + Ok(()) + } + fn required_string(&mut self, index: u32) -> Result<&'a str, LayerCodecError> { + self.reference(index)?; + Ok(self.strings.get(index)) + } + fn optional_string(&mut self, index: u32) -> Result, LayerCodecError> { + if index == NONE_STRING { + Ok(None) + } else { + self.required_string(index).map(Some) + } + } + fn unique_identity( + &mut self, + kind: &'static str, + index: usize, + value: &'a str, + ) -> Result<(), LayerCodecError> { + if !self.identities.entry(kind).or_default().insert(value) { + return Err(LayerCodecError::DuplicateIdentity { kind, index }); + } + Ok(()) + } + fn count_lib_value(&mut self) -> Result<(), LayerCodecError> { + self.lib_values = self + .lib_values + .checked_add(1) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("lib value count", self.lib_values, MAX_LAYER_LIB_VALUES) + } +} + +#[derive(Clone, Copy)] +struct Cursor<'a> { + bytes: &'a [u8], + offset: usize, + end: usize, +} +impl<'a> Cursor<'a> { + fn with_range(bytes: &'a [u8], offset: usize, end: usize) -> Self { + Self { bytes, offset, end } + } + fn take(&mut self, count: usize) -> Result<&'a [u8], LayerCodecError> { + let next = self + .offset + .checked_add(count) + .ok_or(LayerCodecError::LengthOverflow)?; + if next > self.end { + return Err(LayerCodecError::Truncated { + offset: self.offset, + needed: count, + available: self.end.saturating_sub(self.offset), + }); + } + let value = &self.bytes[self.offset..next]; + self.offset = next; + Ok(value) + } + fn u8(&mut self) -> Result { + Ok(self.take(1)?[0]) + } + fn u16(&mut self) -> Result { + Ok(u16::from_le_bytes( + self.take(2)?.try_into().expect("fixed width"), + )) + } + fn u32(&mut self) -> Result { + Ok(u32::from_le_bytes( + self.take(4)?.try_into().expect("fixed width"), + )) + } + fn i64(&mut self) -> Result { + Ok(i64::from_le_bytes( + self.take(8)?.try_into().expect("fixed width"), + )) + } + fn u64(&mut self) -> Result { + Ok(u64::from_le_bytes( + self.take(8)?.try_into().expect("fixed width"), + )) + } + fn f64(&mut self) -> Result { + Ok(f64::from_le_bytes( + self.take(8)?.try_into().expect("fixed width"), + )) + } +} + +fn decode_map( + cursor: &mut Cursor<'_>, + state: &mut DecodeState<'_>, + depth: usize, +) -> Result, LayerCodecError> { + check_depth(depth)?; + let count = cursor.u32()? as usize; + check_limit( + "lib value count", + state.lib_values.saturating_add(count), + MAX_LAYER_LIB_VALUES, + )?; + let mut values = BTreeMap::new(); + let mut previous: Option<&str> = None; + for _ in 0..count { + let key_offset = cursor.offset; + let key = state.required_string(cursor.u32()?)?; + if previous.is_some_and(|value| value.as_bytes() >= key.as_bytes()) { + return Err(LayerCodecError::NonCanonicalMapOrder { offset: key_offset }); + } + previous = Some(key); + let value = decode_lib_value(cursor, state, depth + 1)?; + values.insert(key.to_owned(), value); + } + Ok(values) +} + +fn decode_lib_value( + cursor: &mut Cursor<'_>, + state: &mut DecodeState<'_>, + depth: usize, +) -> Result { + check_depth(depth)?; + state.count_lib_value()?; + let tag_offset = cursor.offset; + let tag = cursor.u8()?; + if cursor.u8()? != 0 || cursor.u16()? != 0 { + return Err(LayerCodecError::NonZeroReserved { + record: "lib value", + index: state.lib_values - 1, + }); + } + match tag { + 0 => Ok(LayerLibValue::String( + state.required_string(cursor.u32()?)?.to_owned(), + )), + 1 => Ok(LayerLibValue::Integer(cursor.i64()?)), + 2 => Ok(LayerLibValue::UnsignedInteger(cursor.u64()?)), + 3 => { + let value = cursor.f64()?; + validate_finite(value, "lib float", state.lib_values - 1)?; + Ok(LayerLibValue::Float(value)) + } + 4 => { + let offset = cursor.offset; + let value = cursor.u8()?; + if cursor.take(3)? != [0, 0, 0] { + return Err(LayerCodecError::NonZeroReserved { + record: "lib boolean", + index: state.lib_values - 1, + }); + } + match value { + 0 => Ok(LayerLibValue::Boolean(false)), + 1 => Ok(LayerLibValue::Boolean(true)), + value => Err(LayerCodecError::InvalidBoolean { offset, value }), + } + } + 5 => { + let count = cursor.u32()? as usize; + check_limit( + "lib value count", + state.lib_values.saturating_add(count), + MAX_LAYER_LIB_VALUES, + )?; + let mut values = Vec::with_capacity(count); + for _ in 0..count { + values.push(decode_lib_value(cursor, state, depth + 1)?); + } + Ok(LayerLibValue::Array(values)) + } + 6 => Ok(LayerLibValue::Dict(decode_map(cursor, state, depth + 1)?)), + 7 => { + let count = cursor.u32()? as usize; + Ok(LayerLibValue::Data(cursor.take(count)?.to_vec())) + } + 8 => Ok(LayerLibValue::Date( + state.required_string(cursor.u32()?)?.to_owned(), + )), + 9 => Ok(LayerLibValue::Uid(cursor.u64()?)), + value => Err(LayerCodecError::UnknownLibTag { + offset: tag_offset, + value, + }), + } +} + +fn gather_map_strings( + values: &BTreeMap, + strings: &mut StringPool, + depth: usize, + count: &mut usize, +) -> Result<(), LayerCodecError> { + check_depth(depth)?; + for (key, value) in sorted_map(values) { + strings.intern(key)?; + gather_value_strings(value, strings, depth + 1, count)?; + } + Ok(()) +} +fn gather_value_strings( + value: &LayerLibValue, + strings: &mut StringPool, + depth: usize, + count: &mut usize, +) -> Result<(), LayerCodecError> { + check_depth(depth)?; + *count = count + .checked_add(1) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("lib value count", *count, MAX_LAYER_LIB_VALUES)?; + match value { + LayerLibValue::String(value) | LayerLibValue::Date(value) => { + strings.intern(value)?; + } + LayerLibValue::Array(values) => { + for value in values { + gather_value_strings(value, strings, depth + 1, count)?; + } + } + LayerLibValue::Dict(values) => gather_map_strings(values, strings, depth + 1, count)?, + LayerLibValue::Integer(_) + | LayerLibValue::UnsignedInteger(_) + | LayerLibValue::Float(_) + | LayerLibValue::Boolean(_) + | LayerLibValue::Data(_) + | LayerLibValue::Uid(_) => {} + } + Ok(()) +} + +fn encode_map( + bytes: &mut Vec, + values: &BTreeMap, + strings: &StringPool, + depth: usize, + count: &mut usize, +) -> Result<(), LayerCodecError> { + check_depth(depth)?; + push_u32(bytes, values.len())?; + for (key, value) in sorted_map(values) { + push_u32(bytes, strings.index(key))?; + encode_lib_value(bytes, value, strings, depth + 1, count)?; + } + Ok(()) +} +fn encode_lib_value( + bytes: &mut Vec, + value: &LayerLibValue, + strings: &StringPool, + depth: usize, + count: &mut usize, +) -> Result<(), LayerCodecError> { + check_depth(depth)?; + *count = count + .checked_add(1) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("lib value count", *count, MAX_LAYER_LIB_VALUES)?; + let tag = match value { + LayerLibValue::String(_) => 0, + LayerLibValue::Integer(_) => 1, + LayerLibValue::UnsignedInteger(_) => 2, + LayerLibValue::Float(_) => 3, + LayerLibValue::Boolean(_) => 4, + LayerLibValue::Array(_) => 5, + LayerLibValue::Dict(_) => 6, + LayerLibValue::Data(_) => 7, + LayerLibValue::Date(_) => 8, + LayerLibValue::Uid(_) => 9, + }; + bytes.extend_from_slice(&[tag, 0, 0, 0]); + match value { + LayerLibValue::String(value) | LayerLibValue::Date(value) => { + push_u32(bytes, strings.index(value))? + } + LayerLibValue::Integer(value) => bytes.extend_from_slice(&value.to_le_bytes()), + LayerLibValue::UnsignedInteger(value) | LayerLibValue::Uid(value) => { + bytes.extend_from_slice(&value.to_le_bytes()) + } + LayerLibValue::Float(value) => bytes.extend_from_slice(&value.to_le_bytes()), + LayerLibValue::Boolean(value) => bytes.extend_from_slice(&[u8::from(*value), 0, 0, 0]), + LayerLibValue::Array(values) => { + push_u32(bytes, values.len())?; + for value in values { + encode_lib_value(bytes, value, strings, depth + 1, count)?; + } + } + LayerLibValue::Dict(values) => encode_map(bytes, values, strings, depth + 1, count)?, + LayerLibValue::Data(value) => { + push_u32(bytes, value.len())?; + bytes.extend_from_slice(value); + } + } + Ok(()) +} + +fn sorted_map(values: &BTreeMap) -> Vec<(&String, &LayerLibValue)> { + let mut values = values.iter().collect::>(); + values.sort_unstable_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); + values +} + +fn validate_strings(strings: Strings<'_>, expected_bytes: usize) -> Result<(), LayerCodecError> { + if read_u32(strings.bytes, strings.offsets_offset) != 0 { + return Err(LayerCodecError::InvalidStringOffsets { index: 0 }); + } + let mut previous = 0usize; + let mut seen = HashSet::with_capacity(strings.count); + for index in 0..strings.count { + let end = read_u32(strings.bytes, strings.offsets_offset + (index + 1) * 4) as usize; + if end < previous || end > expected_bytes { + return Err(LayerCodecError::InvalidStringOffsets { index: index + 1 }); + } + let value = std::str::from_utf8( + &strings.bytes[strings.values_offset + previous..strings.values_offset + end], + ) + .map_err(|_| LayerCodecError::InvalidUtf8 { index })?; + if !seen.insert(value) { + return Err(LayerCodecError::DuplicateString { index }); + } + previous = end; + } + if previous != expected_bytes { + return Err(LayerCodecError::InvalidStringOffsets { + index: strings.count, + }); + } + Ok(()) +} + +fn validate_finite(value: f64, field: &'static str, index: usize) -> Result<(), LayerCodecError> { + if value.is_finite() { + Ok(()) + } else { + Err(LayerCodecError::NonFiniteNumber { field, index }) + } +} +fn validate_absent(value: f64, field: &'static str, index: usize) -> Result<(), LayerCodecError> { + if value.to_bits() == 0 { + Ok(()) + } else { + Err(LayerCodecError::NonCanonicalAbsentNumber { field, index }) + } +} +fn check_depth(depth: usize) -> Result<(), LayerCodecError> { + if depth > MAX_LAYER_LIB_DEPTH { + Err(LayerCodecError::NestingLimitExceeded { + limit: MAX_LAYER_LIB_DEPTH, + }) + } else { + Ok(()) + } +} +fn check_limit(field: &'static str, actual: usize, limit: usize) -> Result<(), LayerCodecError> { + if actual > limit { + Err(LayerCodecError::LimitExceeded { + field, + limit, + actual, + }) + } else { + Ok(()) + } +} +fn require_range(bytes: &[u8], offset: usize, count: usize) -> Result<(), LayerCodecError> { + let end = offset + .checked_add(count) + .ok_or(LayerCodecError::LengthOverflow)?; + if end > bytes.len() { + Err(LayerCodecError::Truncated { + offset, + needed: count, + available: bytes.len().saturating_sub(offset), + }) + } else { + Ok(()) + } +} +fn push_u32(bytes: &mut Vec, value: T) -> Result<(), LayerCodecError> +where + T: TryInto, +{ + let value = value + .try_into() + .ok() + .ok_or(LayerCodecError::LengthOverflow)?; + bytes.extend_from_slice(&value.to_le_bytes()); + Ok(()) +} +fn write_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} +fn write_u32(bytes: &mut [u8], offset: usize, value: T) -> Result<(), LayerCodecError> +where + T: TryInto, +{ + let value = value + .try_into() + .ok() + .ok_or(LayerCodecError::LengthOverflow)?; + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + Ok(()) +} +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().expect("fixed width")) +} +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) +} +fn read_f64(bytes: &[u8], offset: usize) -> f64 { + f64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed width")) +} diff --git a/crates/shift-glyph-codec/src/lib.rs b/crates/shift-glyph-codec/src/lib.rs index 08f09b6b..ae23e416 100644 --- a/crates/shift-glyph-codec/src/lib.rs +++ b/crates/shift-glyph-codec/src/lib.rs @@ -6,6 +6,10 @@ use std::{error::Error, fmt}; +mod layer; + +pub use layer::*; + const MAGIC: &[u8; 4] = b"SHFT"; const OUTLINE_KIND: u8 = 0x01; const OUTLINE_VERSION: u8 = 0x01; diff --git a/crates/shift-glyph-codec/tests/layer.rs b/crates/shift-glyph-codec/tests/layer.rs new file mode 100644 index 00000000..cb8872c6 --- /dev/null +++ b/crates/shift-glyph-codec/tests/layer.rs @@ -0,0 +1,347 @@ +use std::collections::BTreeMap; + +use shift_glyph_codec::{ + decode_layer, pack_layer, GlyphLayer, LayerAnchor, LayerCodecError, LayerComponent, + LayerContour, LayerGuideline, LayerLibValue, LayerPoint, LayerPointType, LayerTransform, +}; + +const EMPTY: &[u8] = include_bytes!("../../../fixtures/glyph-codec/layer-v1/empty.bin"); +const FULL: &[u8] = include_bytes!("../../../fixtures/glyph-codec/layer-v1/full.bin"); + +fn full_layer() -> GlyphLayer { + let mut nested = BTreeMap::new(); + nested.insert( + "alpha".to_string(), + LayerLibValue::String("value".to_string()), + ); + nested.insert("omega".to_string(), LayerLibValue::Uid(u64::MAX)); + + let mut lib = BTreeMap::new(); + lib.insert( + "array".to_string(), + LayerLibValue::Array(vec![ + LayerLibValue::Boolean(false), + LayerLibValue::Boolean(true), + ]), + ); + lib.insert("data".to_string(), LayerLibValue::Data(vec![0, 1, 2, 255])); + lib.insert( + "date".to_string(), + LayerLibValue::Date("2026-07-25T12:34:56Z".to_string()), + ); + lib.insert("dict".to_string(), LayerLibValue::Dict(nested)); + lib.insert("float".to_string(), LayerLibValue::Float(-12.25)); + lib.insert("integer".to_string(), LayerLibValue::Integer(i64::MIN)); + lib.insert( + "string".to_string(), + LayerLibValue::String("layer metadata".to_string()), + ); + lib.insert( + "unsigned".to_string(), + LayerLibValue::UnsignedInteger(u64::MAX), + ); + + GlyphLayer { + id: "layer_A_regular".to_string(), + source_id: "source_regular".to_string(), + width: 600.125, + height: Some(720.5), + contours: vec![ + LayerContour { + id: "contour_outer".to_string(), + closed: true, + points: vec![ + LayerPoint { + id: "point_0".to_string(), + point_type: LayerPointType::OnCurve, + smooth: false, + x: 100.25, + y: -0.0, + }, + LayerPoint { + id: "point_1".to_string(), + point_type: LayerPointType::OffCurve, + smooth: true, + x: 300.5, + y: 700.75, + }, + LayerPoint { + id: "point_2".to_string(), + point_type: LayerPointType::QCurve, + smooth: false, + x: 500.875, + y: 0.0, + }, + ], + }, + LayerContour { + id: "contour_open".to_string(), + closed: false, + points: vec![], + }, + ], + components: vec![LayerComponent { + id: "component_acute".to_string(), + base_glyph_id: "glyph_acute".to_string(), + base_glyph_name: "acutecomb".to_string(), + transform: LayerTransform { + translate_x: 10.0, + translate_y: 20.0, + rotation: 5.5, + scale_x: 1.25, + scale_y: 0.75, + skew_x: 2.0, + skew_y: -1.5, + center_x: 50.0, + center_y: 75.0, + }, + }], + anchors: vec![ + LayerAnchor { + id: "anchor_top".to_string(), + name: Some("top".to_string()), + x: 300.0, + y: 700.0, + }, + LayerAnchor { + id: "anchor_unnamed".to_string(), + name: None, + x: 12.5, + y: -30.25, + }, + ], + guidelines: vec![ + LayerGuideline { + id: "guideline_baseline".to_string(), + x: None, + y: Some(0.0), + angle: None, + name: Some("Baseline".to_string()), + color: Some("0,0,1,1".to_string()), + }, + LayerGuideline { + id: "guideline_angle".to_string(), + x: Some(100.0), + y: Some(200.0), + angle: Some(45.0), + name: None, + color: None, + }, + ], + lib, + } +} + +fn string_section_end(bytes: &[u8]) -> usize { + let count = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + let string_bytes = u32::from_le_bytes(bytes[16..20].try_into().unwrap()) as usize; + 72 + (count + 1) * 4 + string_bytes +} + +#[test] +fn shared_golden_vectors_decode_and_reencode_byte_for_byte() { + let expected = full_layer(); + assert_eq!(decode_layer(FULL).unwrap().unpack(), expected); + assert_eq!(pack_layer(&expected).unwrap().as_bytes(), FULL); + + let empty = GlyphLayer::empty("layer_empty", "source_regular"); + assert_eq!(decode_layer(EMPTY).unwrap().unpack(), empty); + assert_eq!(pack_layer(&empty).unwrap().as_bytes(), EMPTY); +} + +#[test] +fn authored_layer_roundtrips_losslessly() { + let expected = full_layer(); + let packed = pack_layer(&expected).unwrap(); + let view = packed.view(); + + assert_eq!(view.id(), expected.id); + assert_eq!(view.source_id(), expected.source_id); + assert_eq!(view.width().to_bits(), expected.width.to_bits()); + assert_eq!( + view.height().unwrap().to_bits(), + expected.height.unwrap().to_bits() + ); + assert_eq!(view.contour_count(), 2); + assert_eq!(view.point_count(), 3); + assert_eq!(view.component_count(), 1); + assert_eq!(view.anchor_count(), 2); + assert_eq!(view.guideline_count(), 2); + assert_eq!(view.unpack(), expected); +} + +#[test] +fn view_streams_structure_without_materializing_the_layer() { + let packed = pack_layer(&full_layer()).unwrap(); + let view = packed.view(); + let contours = view.contours().collect::>(); + + assert_eq!(contours[0].id(), "contour_outer"); + assert!(contours[0].closed()); + assert_eq!(contours[0].point_count(), 3); + assert_eq!( + contours[0] + .points() + .map(|point| ( + point.id(), + point.point_type(), + point.x().to_bits(), + point.y().to_bits() + )) + .collect::>(), + vec![ + ( + "point_0", + LayerPointType::OnCurve, + 100.25_f64.to_bits(), + (-0.0_f64).to_bits() + ), + ( + "point_1", + LayerPointType::OffCurve, + 300.5_f64.to_bits(), + 700.75_f64.to_bits() + ), + ( + "point_2", + LayerPointType::QCurve, + 500.875_f64.to_bits(), + 0.0_f64.to_bits() + ), + ] + ); + assert_eq!( + view.components().next().unwrap().base_glyph_id(), + "glyph_acute" + ); + assert_eq!(view.anchors().nth(1).unwrap().name(), None); + assert_eq!(view.guidelines().next().unwrap().y(), Some(0.0)); +} + +#[test] +fn encoding_is_deterministic_across_dictionary_insertion_order() { + let first = full_layer(); + let mut second = full_layer(); + second.lib = second.lib.into_iter().rev().collect(); + + assert_eq!(pack_layer(&first).unwrap(), pack_layer(&second).unwrap()); +} + +#[test] +fn decoder_rejects_all_truncations_and_trailing_bytes() { + let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); + for length in 0..bytes.len() { + assert!( + decode_layer(&bytes[..length]).is_err(), + "accepted truncation at {length}" + ); + } + + let mut trailing = bytes; + trailing.push(0); + assert!(matches!( + decode_layer(&trailing), + Err(LayerCodecError::LengthMismatch { .. }) + )); +} + +#[test] +fn decoder_rejects_framing_flags_types_and_noncanonical_absence() { + let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); + + let mut wrong_magic = bytes.clone(); + wrong_magic[0] = 0; + assert_eq!( + decode_layer(&wrong_magic).unwrap_err(), + LayerCodecError::WrongMagic + ); + + let mut wrong_kind = bytes.clone(); + wrong_kind[4] = 1; + assert_eq!( + decode_layer(&wrong_kind).unwrap_err(), + LayerCodecError::UnsupportedPayloadKind(1) + ); + + let mut wrong_version = bytes.clone(); + wrong_version[5] = 2; + assert_eq!( + decode_layer(&wrong_version).unwrap_err(), + LayerCodecError::UnsupportedVersion(2) + ); + + let mut frame_flags = bytes.clone(); + frame_flags[6] = 1; + assert_eq!( + decode_layer(&frame_flags).unwrap_err(), + LayerCodecError::UnknownFlags(1) + ); + + let mut layer_flags = bytes.clone(); + layer_flags[52] = 2; + assert_eq!( + decode_layer(&layer_flags).unwrap_err(), + LayerCodecError::UnknownLayerFlags(2) + ); + + let mut point_type = bytes; + let point_type_offset = string_section_end(&point_type) + 12 + 4; + point_type[point_type_offset] = 3; + assert!(matches!( + decode_layer(&point_type), + Err(LayerCodecError::UnknownPointType { .. }) + )); + + let mut empty = pack_layer(&GlyphLayer::empty("layer_empty", "source_regular")) + .unwrap() + .into_bytes(); + empty[71] = 0x80; + assert!(matches!( + decode_layer(&empty), + Err(LayerCodecError::NonCanonicalAbsentNumber { + field: "height", + .. + }) + )); +} + +#[test] +fn encoder_rejects_nonfinite_numbers_and_duplicate_identity() { + let mut nonfinite = full_layer(); + nonfinite.contours[0].points[0].x = f64::NAN; + assert!(matches!( + pack_layer(&nonfinite), + Err(LayerCodecError::NonFiniteNumber { .. }) + )); + + let mut duplicate = full_layer(); + duplicate.contours[0].points[1].id = duplicate.contours[0].points[0].id.clone(); + assert!(matches!( + pack_layer(&duplicate), + Err(LayerCodecError::DuplicateIdentity { kind: "point", .. }) + )); +} + +#[test] +fn mutated_payloads_never_panic_and_accepted_bytes_reencode_canonically() { + let seed = pack_layer(&full_layer()).unwrap().into_bytes(); + let mut random = 0x81d2_67a5_u32; + + for iteration in 0..10_000 { + random ^= random << 13; + random ^= random >> 17; + random ^= random << 5; + let mut candidate = seed.clone(); + let offset = random as usize % candidate.len(); + candidate[offset] ^= (random >> 24) as u8 | 1; + if iteration % 7 == 0 { + candidate.truncate(random as usize % candidate.len()); + } else if iteration % 11 == 0 { + candidate.push((random >> 16) as u8); + } + + if let Ok(view) = decode_layer(&candidate) { + assert_eq!(pack_layer(&view.unpack()).unwrap().as_bytes(), candidate); + } + } +} diff --git a/fixtures/glyph-codec/layer-v1/empty.bin b/fixtures/glyph-codec/layer-v1/empty.bin new file mode 100644 index 0000000000000000000000000000000000000000..ea9ac0dd6bae539d7213f0bf9bd6c0a53ff4d08c GIT binary patch literal 113 zcmWIca0_8#WMC*{U|?VZVo4yz23VkiK&3bVH&6zoKPRy=wJ1I{x1gl5IKQ+gIW@j0 MHN7+^u?VCS0D8m>djJ3c literal 0 HcmV?d00001 diff --git a/fixtures/glyph-codec/layer-v1/full.bin b/fixtures/glyph-codec/layer-v1/full.bin new file mode 100644 index 0000000000000000000000000000000000000000..c3aa548442820fd768f1d9a656f145d0f44f3b67 GIT binary patch literal 945 zcmY+DJ8#rL6oqG(_ahJ<32#I}LC{9BO%hNF7$_nT5(T6ojWw|+UQ6B?Yt}}rC|Dta z#BZQMqNAgvfrSF`8z>MG5=ujZD4;Uu&e%(GwV%(qcOE+r``*o^<#vm)H`^FH%NU!2 zZ$ptXSnDZ&2`H#W*!b{-TYdV>>OmxDz(wGLE8rTq4OV~z3Oodlz;o~dyaKPmTksBi z1fRec@D+Rq8{jAS1^$8xOkg3j-b@Un39pAnCS@i}UzaA5O%`cYpb+Y^kS5ouDnieR z?<5%a$fb&OFV{+UF%1-|_!<5Gu)}Fh&ewQ3xOdFIbrMF3$$@%YLwUX>M_TVI@wZLS%VYJP=tabx$T@ zEz_dFE@UE2zc4B0q3xgRNjr@wRX^yhrT&>f!{-*Z;u~94IHf4X_JH6%d(Ote5g?+Bb*r;C9 zw{^ZBwF=>yX1G;;bud2b{P<3W7e*(MH`f3=JcS3vX+R$o I&DV_m2N4{$;Q#;t literal 0 HcmV?d00001 diff --git a/fixtures/glyph-codec/layer-v1/vectors.json b/fixtures/glyph-codec/layer-v1/vectors.json new file mode 100644 index 00000000..97881074 --- /dev/null +++ b/fixtures/glyph-codec/layer-v1/vectors.json @@ -0,0 +1,17 @@ +{ + "format": "shift.glyph-layer.v1", + "vectors": [ + { + "file": "empty.bin", + "description": "Empty layer with absent height and empty lib", + "bytes": 113, + "sha256": "87863431c162f63bedfb5a0790bae4f67d09a5eb924c22046fc199d8efb16edb" + }, + { + "file": "full.bin", + "description": "All point types, closure states, identity-bearing entities, optional fields, transforms, and lib value tags", + "bytes": 945, + "sha256": "1cc4172f22a4cd01682b8f328cc9df5e091c9ab143fff173ebdba0d32c7afc8f" + } + ] +} diff --git a/packages/glyph-codec/README.md b/packages/glyph-codec/README.md index 71476a5d..cc354945 100644 --- a/packages/glyph-codec/README.md +++ b/packages/glyph-codec/README.md @@ -2,8 +2,45 @@ DOM-free TypeScript implementation of Shift's packed glyph payload family. -The normative byte layout and compatibility rules live in +The normative byte layouts and compatibility rules live in [`crates/shift-glyph-codec/SPECIFICATION.md`](../../crates/shift-glyph-codec/SPECIFICATION.md). -This package owns strict validation and the opaque `PackedGlyphOutline` value; -transport, persistence, `Path2D`, SVG debugging, and Slug transformations belong -to their respective consumers. + +The package owns: + +- strict `shift.glyph-outline.v1` validation and `PackedGlyphOutline`; +- strict, lossless `shift.glyph-layer.v1` validation and `PackedGlyphLayer`; +- canonical encoders shared with Rust golden vectors; +- iterable layer/contour views that avoid constructing a complete font. + +`PackedGlyphOutline` is flattened, derived `f32` rendering data. +`PackedGlyphLayer` is canonical authored `f64` state with stable identities, +point semantics, components, anchors, guidelines, and lib values. They are not +interchangeable. + +Transport, persistence, `Path2D`, SVG debugging, SQLite, and Slug/GPU +transformations belong to their respective consumers. Rust adaptation to the +editable object model lives in `shift-font::packed_layer`, keeping the codec +independent of font semantics. + +```ts +import { decodeLayer, packLayer } from "@shift/glyph-codec"; + +const packed = packLayer(layer); +for (const contour of packed.contours()) { + for (const point of contour.points()) { + // Build a bounded derived projection without materializing every layer. + } +} + +const validated = decodeLayer(bytes); +const editableLayer = validated.unpack(); +``` + +Verification: + +```bash +pnpm --filter @shift/glyph-codec typecheck +pnpm --filter @shift/glyph-codec test +cargo test -p shift-glyph-codec +cargo run --release -p shift-glyph-codec --example layer_benchmark +``` diff --git a/packages/glyph-codec/src/error.ts b/packages/glyph-codec/src/error.ts new file mode 100644 index 00000000..4ec01091 --- /dev/null +++ b/packages/glyph-codec/src/error.ts @@ -0,0 +1,15 @@ +import type { GlyphCodecErrorCode } from "./types"; + +export class GlyphCodecError extends Error { + readonly code: GlyphCodecErrorCode; + + constructor(code: GlyphCodecErrorCode, message: string) { + super(message); + this.name = "GlyphCodecError"; + this.code = code; + } +} + +export function fail(code: GlyphCodecErrorCode, message: string): never { + throw new GlyphCodecError(code, message); +} diff --git a/packages/glyph-codec/src/index.ts b/packages/glyph-codec/src/index.ts index f180933b..ccc078cd 100644 --- a/packages/glyph-codec/src/index.ts +++ b/packages/glyph-codec/src/index.ts @@ -1,10 +1,36 @@ +export { GlyphCodecError } from "./error"; +export { + decodeLayer, + LayerContourView, + MAX_LAYER_CONTOUR_COUNT, + MAX_LAYER_ENTITY_COUNT, + MAX_LAYER_LIB_DEPTH, + MAX_LAYER_LIB_VALUES, + MAX_LAYER_PAYLOAD_BYTES, + MAX_LAYER_POINT_COUNT, + MAX_LAYER_STRING_BYTES, + MAX_LAYER_STRING_COUNT, + PackedGlyphLayer, + packLayer, +} from "./layer"; export { decodeOutline, - GlyphCodecError, MAX_COMMAND_COUNT, MAX_COORDINATE_COUNT, MAX_PAYLOAD_BYTES, PackedGlyphOutline, packOutline, } from "./outline"; -export type { GlyphCodecErrorCode, OutlineCommand } from "./types"; +export type { + GlyphCodecErrorCode, + GlyphLayer, + LayerAnchor, + LayerComponent, + LayerContour, + LayerGuideline, + LayerLibValue, + LayerPoint, + LayerPointType, + LayerTransform, + OutlineCommand, +} from "./types"; diff --git a/packages/glyph-codec/src/layer.test.ts b/packages/glyph-codec/src/layer.test.ts new file mode 100644 index 00000000..ae652170 --- /dev/null +++ b/packages/glyph-codec/src/layer.test.ts @@ -0,0 +1,295 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { decodeLayer, GlyphCodecError, packLayer } from "./layer"; +import type { GlyphCodecErrorCode, GlyphLayer } from "./types"; + +function fullLayer(): GlyphLayer { + return { + id: "layer_A_regular", + sourceId: "source_regular", + width: 600.125, + height: 720.5, + contours: [ + { + id: "contour_outer", + closed: true, + points: [ + { id: "point_0", type: "on-curve", smooth: false, x: 100.25, y: -0 }, + { id: "point_1", type: "off-curve", smooth: true, x: 300.5, y: 700.75 }, + { id: "point_2", type: "qcurve", smooth: false, x: 500.875, y: 0 }, + ], + }, + { id: "contour_open", closed: false, points: [] }, + ], + components: [ + { + id: "component_acute", + baseGlyphId: "glyph_acute", + baseGlyphName: "acutecomb", + transform: { + translateX: 10, + translateY: 20, + rotation: 5.5, + scaleX: 1.25, + scaleY: 0.75, + skewX: 2, + skewY: -1.5, + centerX: 50, + centerY: 75, + }, + }, + ], + anchors: [ + { id: "anchor_top", name: "top", x: 300, y: 700 }, + { id: "anchor_unnamed", name: null, x: 12.5, y: -30.25 }, + ], + guidelines: [ + { + id: "guideline_baseline", + x: null, + y: 0, + angle: null, + name: "Baseline", + color: "0,0,1,1", + }, + { + id: "guideline_angle", + x: 100, + y: 200, + angle: 45, + name: null, + color: null, + }, + ], + lib: new Map([ + [ + "array", + { + kind: "array" as const, + value: [ + { kind: "boolean" as const, value: false }, + { kind: "boolean" as const, value: true }, + ], + }, + ], + ["data", { kind: "data" as const, value: Uint8Array.from([0, 1, 2, 255]) }], + ["date", { kind: "date" as const, value: "2026-07-25T12:34:56Z" }], + [ + "dict", + { + kind: "dict" as const, + value: new Map([ + ["alpha", { kind: "string" as const, value: "value" }], + ["omega", { kind: "uid" as const, value: 0xffff_ffff_ffff_ffffn }], + ]), + }, + ], + ["float", { kind: "float" as const, value: -12.25 }], + ["integer", { kind: "integer" as const, value: -0x8000_0000_0000_0000n }], + ["string", { kind: "string" as const, value: "layer metadata" }], + ["unsigned", { kind: "unsigned-integer" as const, value: 0xffff_ffff_ffff_ffffn }], + ]), + }; +} + +function emptyLayer(): GlyphLayer { + return { + id: "layer_empty", + sourceId: "source_regular", + width: 0, + height: null, + contours: [], + components: [], + anchors: [], + guidelines: [], + lib: new Map(), + }; +} + +function fixture(file: string): Uint8Array { + return readFileSync(new URL(`../../../fixtures/glyph-codec/layer-v1/${file}`, import.meta.url)); +} + +function errorCode(action: () => unknown): GlyphCodecErrorCode | null { + try { + action(); + return null; + } catch (error) { + if (!(error instanceof GlyphCodecError)) throw error; + return error.code; + } +} + +function stringSectionEnd(bytes: Uint8Array): number { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return 72 + (view.getUint32(12, true) + 1) * 4 + view.getUint32(16, true); +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +describe("shift.glyph-layer.v1", () => { + it("shares byte-for-byte golden vectors with Rust", () => { + const expected = fullLayer(); + const full = fixture("full.bin"); + expect(decodeLayer(full).unpack()).toEqual(expected); + expect([...packLayer(expected).toUint8Array()]).toEqual([...full]); + + const empty = fixture("empty.bin"); + expect(decodeLayer(empty).unpack()).toEqual(emptyLayer()); + expect([...packLayer(emptyLayer()).toUint8Array()]).toEqual([...empty]); + }); + + it("roundtrips the complete authored model losslessly", () => { + const expected = fullLayer(); + const packed = packLayer(expected); + const decoded = packed.unpack(); + + expect(packed.id).toBe(expected.id); + expect(packed.sourceId).toBe(expected.sourceId); + expect(packed.width).toBe(expected.width); + expect(packed.height).toBe(expected.height); + expect(packed.contourCount).toBe(2); + expect(packed.pointCount).toBe(3); + expect(packed.componentCount).toBe(1); + expect(packed.anchorCount).toBe(2); + expect(packed.guidelineCount).toBe(2); + expect(decoded).toEqual(expected); + expect(Object.is(decoded.contours[0].points[0].y, -0)).toBe(true); + }); + + it("streams one contour and entity at a time", () => { + const packed = packLayer(fullLayer()); + const contours = [...packed.contours()]; + + expect(contours[0].id).toBe("contour_outer"); + expect(contours[0].closed).toBe(true); + expect([...contours[0].points()].map((point) => [point.id, point.type])).toEqual([ + ["point_0", "on-curve"], + ["point_1", "off-curve"], + ["point_2", "qcurve"], + ]); + expect([...packed.components()][0].baseGlyphId).toBe("glyph_acute"); + expect([...packed.anchors()][1].name).toBeNull(); + expect([...packed.guidelines()][0].y).toBe(0); + }); + + it("encodes dictionaries canonically regardless of insertion order", () => { + const first = fullLayer(); + const second = { ...fullLayer(), lib: new Map([...fullLayer().lib].reverse()) }; + + expect([...packLayer(first).toUint8Array()]).toEqual([...packLayer(second).toUint8Array()]); + }); + + it("does not expose mutable validated storage", () => { + const source = packLayer(fullLayer()).toUint8Array(); + const layer = decodeLayer(source); + const exported = layer.toUint8Array(); + source[0] = 0; + exported[1] = 0; + + expect(layer.id).toBe("layer_A_regular"); + expect(layer.pointCount).toBe(3); + }); +}); + +describe("strict layer decoding", () => { + it("rejects every truncation and trailing bytes", () => { + const bytes = packLayer(fullLayer()).toUint8Array(); + for (let length = 0; length < bytes.length; length += 1) { + expect(errorCode(() => decodeLayer(bytes.slice(0, length)))).not.toBeNull(); + } + + expect(errorCode(() => decodeLayer(Uint8Array.from([...bytes, 0])))).toBe("length-mismatch"); + }); + + it("rejects framing, unknown flags and point types", () => { + const seed = packLayer(fullLayer()).toUint8Array(); + const cases: ReadonlyArray = [ + [0, 0, "wrong-magic"], + [4, 1, "unsupported-kind"], + [5, 2, "unsupported-version"], + [6, 1, "unknown-flags"], + [52, 2, "unknown-layer-flags"], + [stringSectionEnd(seed) + 12 + 4, 3, "unknown-point-type"], + ]; + + for (const [offset, value, code] of cases) { + const bytes = seed.slice(); + bytes[offset] = value; + expect(errorCode(() => decodeLayer(bytes))).toBe(code); + } + }); + + it("rejects non-canonical absent numeric slots", () => { + const bytes = packLayer(emptyLayer()).toUint8Array(); + bytes[71] = 0x80; + + expect(errorCode(() => decodeLayer(bytes))).toBe("noncanonical-absent-number"); + }); + + it("rejects non-finite numbers, duplicate identity, and out-of-range integers", () => { + const source = fullLayer(); + const [firstContour, ...otherContours] = source.contours; + const [firstPoint, secondPoint, ...otherPoints] = firstContour.points; + const nonfinite: GlyphLayer = { + ...source, + contours: [ + { + ...firstContour, + points: [{ ...firstPoint, x: Number.NaN }, secondPoint, ...otherPoints], + }, + ...otherContours, + ], + }; + expect(errorCode(() => packLayer(nonfinite))).toBe("non-finite-number"); + + const duplicate: GlyphLayer = { + ...source, + contours: [ + { + ...firstContour, + points: [firstPoint, { ...secondPoint, id: firstPoint.id }, ...otherPoints], + }, + ...otherContours, + ], + }; + expect(errorCode(() => packLayer(duplicate))).toBe("duplicate-identity"); + + const integer: GlyphLayer = { + ...source, + lib: new Map([["too-large", { kind: "uid", value: 1n << 64n }]]), + }; + expect(errorCode(() => packLayer(integer))).toBe("integer-out-of-range"); + }); + + it("never accepts a mutated non-canonical payload", () => { + const seed = packLayer(fullLayer()).toUint8Array(); + let random = 0x81d2_67a5; + + for (let iteration = 0; iteration < 10_000; iteration += 1) { + random ^= random << 13; + random ^= random >>> 17; + random ^= random << 5; + const candidate = seed.slice(); + const offset = (random >>> 0) % candidate.length; + candidate[offset] ^= ((random >>> 24) | 1) & 0xff; + const mutated = + iteration % 7 === 0 + ? candidate.slice(0, (random >>> 0) % candidate.length) + : iteration % 11 === 0 + ? Uint8Array.from([...candidate, (random >>> 16) & 0xff]) + : candidate; + + try { + const layer = decodeLayer(mutated); + if (!bytesEqual(packLayer(layer.unpack()).toUint8Array(), mutated)) { + throw new Error("decoder accepted a non-canonical layer encoding"); + } + } catch (error) { + if (!(error instanceof GlyphCodecError)) throw error; + } + } + }); +}); diff --git a/packages/glyph-codec/src/layer.ts b/packages/glyph-codec/src/layer.ts new file mode 100644 index 00000000..6465fef4 --- /dev/null +++ b/packages/glyph-codec/src/layer.ts @@ -0,0 +1,1162 @@ +import { fail } from "./error"; +import type { + GlyphLayer, + LayerAnchor, + LayerComponent, + LayerContour, + LayerGuideline, + LayerLibValue, + LayerPoint, + LayerPointType, + LayerTransform, +} from "./types"; + +export { GlyphCodecError } from "./error"; + +const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; +const LAYER_KIND = 0x02; +const LAYER_VERSION = 0x01; +const HEADER_LENGTH = 72; +const NONE_STRING = 0xffff_ffff; +const CONTOUR_LENGTH = 12; +const POINT_LENGTH = 24; +const COMPONENT_LENGTH = 88; +const ANCHOR_LENGTH = 24; +const GUIDELINE_LENGTH = 40; +const MIN_I64 = -(1n << 63n); +const MAX_I64 = (1n << 63n) - 1n; +const MAX_U64 = (1n << 64n) - 1n; + +export const MAX_LAYER_CONTOUR_COUNT = 1_000_000; +export const MAX_LAYER_POINT_COUNT = 4_000_000; +export const MAX_LAYER_ENTITY_COUNT = 1_000_000; +export const MAX_LAYER_STRING_COUNT = 4_000_000; +export const MAX_LAYER_STRING_BYTES = 64 * 1024 * 1024; +export const MAX_LAYER_LIB_VALUES = 1_000_000; +export const MAX_LAYER_LIB_DEPTH = 64; +export const MAX_LAYER_PAYLOAD_BYTES = 256 * 1024 * 1024; + +type Header = { + readonly strings: readonly string[]; + readonly stringCount: number; + readonly contourCount: number; + readonly pointCount: number; + readonly componentCount: number; + readonly anchorCount: number; + readonly guidelineCount: number; + readonly idIndex: number; + readonly sourceIdIndex: number; + readonly width: number; + readonly height: number | null; + readonly contoursOffset: number; + readonly componentsOffset: number; + readonly anchorsOffset: number; + readonly guidelinesOffset: number; + readonly libOffset: number; + readonly libEnd: number; +}; + +/** One contour backed by validated packed bytes. */ +export class LayerContourView { + readonly #bytes: Uint8Array; + readonly #strings: readonly string[]; + readonly #pointsOffset: number; + readonly id: string; + readonly closed: boolean; + readonly pointCount: number; + + constructor( + bytes: Uint8Array, + strings: readonly string[], + id: string, + closed: boolean, + pointCount: number, + pointsOffset: number, + ) { + this.#bytes = bytes; + this.#strings = strings; + this.#pointsOffset = pointsOffset; + this.id = id; + this.closed = closed; + this.pointCount = pointCount; + } + + *points(): IterableIterator { + const view = dataView(this.#bytes); + let offset = this.#pointsOffset; + for (let index = 0; index < this.pointCount; index += 1) { + yield { + id: this.#strings[view.getUint32(offset, true)], + type: pointTypeFromByte(this.#bytes[offset + 4], index), + smooth: (this.#bytes[offset + 5] & 1) !== 0, + x: view.getFloat64(offset + 8, true), + y: view.getFloat64(offset + 16, true), + }; + offset += POINT_LENGTH; + } + } + + unpack(): LayerContour { + return { id: this.id, closed: this.closed, points: [...this.points()] }; + } +} + +/** + * Opaque, immutable, validated `shift.glyph-layer.v1` bytes. + * + * Decoding copies caller-owned bytes. Iterators decode one authored entity at a + * time and never construct a complete font or retain unrelated layers. + */ +export class PackedGlyphLayer { + readonly #bytes: Uint8Array; + readonly #header: Header; + + private constructor(bytes: Uint8Array, header: Header) { + this.#bytes = bytes; + this.#header = header; + } + + static decode(data: Uint8Array): PackedGlyphLayer { + checkLimit("payload byte length", data.byteLength, MAX_LAYER_PAYLOAD_BYTES); + const bytes = Uint8Array.from(data); + return new PackedGlyphLayer(bytes, validate(bytes)); + } + + static pack(layer: GlyphLayer): PackedGlyphLayer { + const [bytes, header] = encode(layer); + return new PackedGlyphLayer(bytes, header); + } + + get byteLength(): number { + return this.#bytes.byteLength; + } + + get id(): string { + return this.#header.strings[this.#header.idIndex]; + } + + get sourceId(): string { + return this.#header.strings[this.#header.sourceIdIndex]; + } + + get width(): number { + return this.#header.width; + } + + get height(): number | null { + return this.#header.height; + } + + get contourCount(): number { + return this.#header.contourCount; + } + + get pointCount(): number { + return this.#header.pointCount; + } + + get componentCount(): number { + return this.#header.componentCount; + } + + get anchorCount(): number { + return this.#header.anchorCount; + } + + get guidelineCount(): number { + return this.#header.guidelineCount; + } + + toUint8Array(): Uint8Array { + return this.#bytes.slice(); + } + + *contours(): IterableIterator { + const view = dataView(this.#bytes); + let offset = this.#header.contoursOffset; + for (let index = 0; index < this.#header.contourCount; index += 1) { + const pointCount = view.getUint32(offset + 4, true); + const pointsOffset = offset + CONTOUR_LENGTH; + yield new LayerContourView( + this.#bytes, + this.#header.strings, + this.#header.strings[view.getUint32(offset, true)], + (view.getUint32(offset + 8, true) & 1) !== 0, + pointCount, + pointsOffset, + ); + offset = pointsOffset + pointCount * POINT_LENGTH; + } + } + + *components(): IterableIterator { + const view = dataView(this.#bytes); + let offset = this.#header.componentsOffset; + for (let index = 0; index < this.#header.componentCount; index += 1) { + yield { + id: this.#header.strings[view.getUint32(offset, true)], + baseGlyphId: this.#header.strings[view.getUint32(offset + 4, true)], + baseGlyphName: this.#header.strings[view.getUint32(offset + 8, true)], + transform: readTransform(view, offset + 16), + }; + offset += COMPONENT_LENGTH; + } + } + + *anchors(): IterableIterator { + const view = dataView(this.#bytes); + let offset = this.#header.anchorsOffset; + for (let index = 0; index < this.#header.anchorCount; index += 1) { + yield { + id: this.#header.strings[view.getUint32(offset, true)], + name: optionalString(this.#header.strings, view.getUint32(offset + 4, true)), + x: view.getFloat64(offset + 8, true), + y: view.getFloat64(offset + 16, true), + }; + offset += ANCHOR_LENGTH; + } + } + + *guidelines(): IterableIterator { + const view = dataView(this.#bytes); + let offset = this.#header.guidelinesOffset; + for (let index = 0; index < this.#header.guidelineCount; index += 1) { + const flags = view.getUint32(offset + 12, true); + yield { + id: this.#header.strings[view.getUint32(offset, true)], + name: optionalString(this.#header.strings, view.getUint32(offset + 4, true)), + color: optionalString(this.#header.strings, view.getUint32(offset + 8, true)), + x: (flags & 1) === 0 ? null : view.getFloat64(offset + 16, true), + y: (flags & 2) === 0 ? null : view.getFloat64(offset + 24, true), + angle: (flags & 4) === 0 ? null : view.getFloat64(offset + 32, true), + }; + offset += GUIDELINE_LENGTH; + } + } + + lib(): ReadonlyMap { + const cursor = new Cursor(this.#bytes, this.#header.libOffset, this.#header.libEnd); + return decodeMap(cursor, new DecodeState(this.#header.strings, false), 0); + } + + unpack(): GlyphLayer { + return { + id: this.id, + sourceId: this.sourceId, + width: this.width, + height: this.height, + contours: [...this.contours()].map((contour) => contour.unpack()), + components: [...this.components()], + anchors: [...this.anchors()], + guidelines: [...this.guidelines()], + lib: this.lib(), + }; + } +} + +export function packLayer(layer: GlyphLayer): PackedGlyphLayer { + return PackedGlyphLayer.pack(layer); +} + +export function decodeLayer(data: Uint8Array): PackedGlyphLayer { + return PackedGlyphLayer.decode(data); +} + +function encode(layer: GlyphLayer): readonly [Uint8Array, Header] { + checkLimit("contour count", layer.contours.length, MAX_LAYER_CONTOUR_COUNT); + checkLimit("component count", layer.components.length, MAX_LAYER_ENTITY_COUNT); + checkLimit("anchor count", layer.anchors.length, MAX_LAYER_ENTITY_COUNT); + checkLimit("guideline count", layer.guidelines.length, MAX_LAYER_ENTITY_COUNT); + + let pointCount = 0; + for (const contour of layer.contours) { + pointCount = checkedAdd(pointCount, contour.points.length); + checkLimit("point count", pointCount, MAX_LAYER_POINT_COUNT); + } + + const strings = new StringPool(); + strings.intern(layer.id); + strings.intern(layer.sourceId); + for (const contour of layer.contours) { + strings.intern(contour.id); + for (const point of contour.points) strings.intern(point.id); + } + for (const component of layer.components) { + strings.intern(component.id); + strings.intern(component.baseGlyphId); + strings.intern(component.baseGlyphName); + } + for (const anchor of layer.anchors) { + strings.intern(anchor.id); + if (anchor.name !== null) strings.intern(anchor.name); + } + for (const guideline of layer.guidelines) { + strings.intern(guideline.id); + if (guideline.name !== null) strings.intern(guideline.name); + if (guideline.color !== null) strings.intern(guideline.color); + } + const libState = { count: 0 }; + gatherMapStrings(layer.lib, strings, 0, libState); + + const libBytes = mapByteLength(layer.lib, 0, { count: 0 }); + const contoursBytes = layer.contours.reduce( + (total, contour) => + checkedAdd( + total, + checkedAdd(CONTOUR_LENGTH, checkedMultiply(contour.points.length, POINT_LENGTH)), + ), + 0, + ); + const byteLength = [ + HEADER_LENGTH, + checkedMultiply(strings.count + 1, 4), + strings.byteLength, + contoursBytes, + checkedMultiply(layer.components.length, COMPONENT_LENGTH), + checkedMultiply(layer.anchors.length, ANCHOR_LENGTH), + checkedMultiply(layer.guidelines.length, GUIDELINE_LENGTH), + libBytes, + ].reduce(checkedAdd, 0); + checkLimit("payload byte length", byteLength, MAX_LAYER_PAYLOAD_BYTES); + + const bytes = new Uint8Array(byteLength); + const view = dataView(bytes); + bytes.set(MAGIC, 0); + bytes[4] = LAYER_KIND; + bytes[5] = LAYER_VERSION; + view.setUint16(6, 0, true); + view.setUint32(8, byteLength, true); + view.setUint32(12, strings.count, true); + view.setUint32(16, strings.byteLength, true); + view.setUint32(20, layer.contours.length, true); + view.setUint32(24, pointCount, true); + view.setUint32(28, layer.components.length, true); + view.setUint32(32, layer.anchors.length, true); + view.setUint32(36, layer.guidelines.length, true); + view.setUint32(40, libBytes, true); + view.setUint32(44, strings.index(layer.id), true); + view.setUint32(48, strings.index(layer.sourceId), true); + view.setUint32(52, layer.height === null ? 0 : 1, true); + view.setFloat64(56, layer.width, true); + view.setFloat64(64, layer.height ?? 0, true); + + const writer = new Writer(bytes, HEADER_LENGTH); + writer.u32(0); + let stringOffset = 0; + for (const encoded of strings.encodedValues) { + stringOffset = checkedAdd(stringOffset, encoded.byteLength); + writer.u32(stringOffset); + } + for (const encoded of strings.encodedValues) writer.bytes(encoded); + + for (const contour of layer.contours) { + writer.u32(strings.index(contour.id)); + writer.u32(contour.points.length); + writer.u32(contour.closed ? 1 : 0); + for (const point of contour.points) { + writer.u32(strings.index(point.id)); + writer.u8(pointTypeByte(point.type)); + writer.u8(point.smooth ? 1 : 0); + writer.u16(0); + writer.f64(point.x); + writer.f64(point.y); + } + } + for (const component of layer.components) { + writer.u32(strings.index(component.id)); + writer.u32(strings.index(component.baseGlyphId)); + writer.u32(strings.index(component.baseGlyphName)); + writer.u32(0); + for (const value of transformValues(component.transform)) writer.f64(value); + } + for (const anchor of layer.anchors) { + writer.u32(strings.index(anchor.id)); + writer.u32(anchor.name === null ? NONE_STRING : strings.index(anchor.name)); + writer.f64(anchor.x); + writer.f64(anchor.y); + } + for (const guideline of layer.guidelines) { + writer.u32(strings.index(guideline.id)); + writer.u32(guideline.name === null ? NONE_STRING : strings.index(guideline.name)); + writer.u32(guideline.color === null ? NONE_STRING : strings.index(guideline.color)); + writer.u32( + (guideline.x === null ? 0 : 1) | + (guideline.y === null ? 0 : 2) | + (guideline.angle === null ? 0 : 4), + ); + writer.f64(guideline.x ?? 0); + writer.f64(guideline.y ?? 0); + writer.f64(guideline.angle ?? 0); + } + encodeMap(writer, layer.lib, strings, 0, { count: 0 }); + if (writer.offset !== byteLength) + fail("length-mismatch", "internal glyph-layer encoder length mismatch"); + + return [bytes, validate(bytes)]; +} + +function validate(bytes: Uint8Array): Header { + if (bytes.byteLength < HEADER_LENGTH) { + fail( + "header-truncated", + `glyph-layer header is truncated: ${bytes.byteLength} of ${HEADER_LENGTH} bytes`, + ); + } + checkLimit("payload byte length", bytes.byteLength, MAX_LAYER_PAYLOAD_BYTES); + if (MAGIC.some((byte, index) => bytes[index] !== byte)) + fail("wrong-magic", "wrong glyph-codec magic"); + if (bytes[4] !== LAYER_KIND) + fail("unsupported-kind", `unsupported glyph-codec payload kind ${hex(bytes[4])}`); + if (bytes[5] !== LAYER_VERSION) + fail("unsupported-version", `unsupported glyph-layer version ${bytes[5]}`); + + const view = dataView(bytes); + const frameFlags = view.getUint16(6, true); + if (frameFlags !== 0) + fail("unknown-flags", `unknown glyph-layer frame flags ${hex(frameFlags, 4)}`); + const declaredLength = view.getUint32(8, true); + if (declaredLength !== bytes.byteLength) + fail( + "length-mismatch", + `glyph-layer payload length mismatch: expected ${declaredLength} bytes, got ${bytes.byteLength}`, + ); + + const stringCount = view.getUint32(12, true); + const stringBytes = view.getUint32(16, true); + const contourCount = view.getUint32(20, true); + const pointCount = view.getUint32(24, true); + const componentCount = view.getUint32(28, true); + const anchorCount = view.getUint32(32, true); + const guidelineCount = view.getUint32(36, true); + const libBytes = view.getUint32(40, true); + const idIndex = view.getUint32(44, true); + const sourceIdIndex = view.getUint32(48, true); + const layerFlags = view.getUint32(52, true); + if ((layerFlags & ~1) !== 0) + fail("unknown-layer-flags", `unknown glyph-layer flags ${hex(layerFlags, 8)}`); + const width = view.getFloat64(56, true); + validateFinite(width, "width", 0); + const rawHeight = view.getFloat64(64, true); + const height = + (layerFlags & 1) === 0 + ? (validateAbsent(view, 64, "height", 0), null) + : (validateFinite(rawHeight, "height", 0), rawHeight); + + checkLimit("string count", stringCount, MAX_LAYER_STRING_COUNT); + checkLimit("string bytes", stringBytes, MAX_LAYER_STRING_BYTES); + checkLimit("contour count", contourCount, MAX_LAYER_CONTOUR_COUNT); + checkLimit("point count", pointCount, MAX_LAYER_POINT_COUNT); + checkLimit("component count", componentCount, MAX_LAYER_ENTITY_COUNT); + checkLimit("anchor count", anchorCount, MAX_LAYER_ENTITY_COUNT); + checkLimit("guideline count", guidelineCount, MAX_LAYER_ENTITY_COUNT); + + const offsetsBytes = checkedMultiply(checkedAdd(stringCount, 1), 4); + const valuesOffset = checkedAdd(HEADER_LENGTH, offsetsBytes); + const stringsEnd = checkedAdd(valuesOffset, stringBytes); + requireRange(bytes, HEADER_LENGTH, checkedAdd(offsetsBytes, stringBytes)); + const strings = validateStrings(bytes, stringCount, stringBytes, valuesOffset); + const state = new DecodeState(strings, true); + state.reference(idIndex); + state.reference(sourceIdIndex); + const cursor = new Cursor(bytes, stringsEnd, bytes.byteLength); + const contoursOffset = cursor.offset; + let actualPoints = 0; + for (let contourIndex = 0; contourIndex < contourCount; contourIndex += 1) { + state.unique("contour", contourIndex, state.requiredString(cursor.u32())); + const count = cursor.u32(); + actualPoints = checkedAdd(actualPoints, count); + checkLimit("point count", actualPoints, MAX_LAYER_POINT_COUNT); + const flags = cursor.u32(); + if ((flags & ~1) !== 0) + fail( + "unknown-record-flags", + `unknown contour flags ${hex(flags, 8)} at index ${contourIndex}`, + ); + for (let localIndex = 0; localIndex < count; localIndex += 1) { + const pointIndex = state.pointIndex; + state.unique("point", pointIndex, state.requiredString(cursor.u32())); + pointTypeFromByte(cursor.u8(), pointIndex); + const pointFlags = cursor.u8(); + if ((pointFlags & ~1) !== 0) + fail( + "unknown-record-flags", + `unknown point flags ${hex(pointFlags)} at index ${pointIndex}`, + ); + if (cursor.u16() !== 0) + fail("non-zero-reserved", `non-zero reserved point bytes at index ${pointIndex}`); + validateFinite(cursor.f64(), "point coordinate", pointIndex * 2); + validateFinite(cursor.f64(), "point coordinate", pointIndex * 2 + 1); + state.pointIndex += 1; + } + } + if (actualPoints !== pointCount) + fail( + "point-count-mismatch", + `contours contain ${actualPoints} points, header declares ${pointCount}`, + ); + + const componentsOffset = cursor.offset; + for (let index = 0; index < componentCount; index += 1) { + state.unique("component", index, state.requiredString(cursor.u32())); + state.requiredString(cursor.u32()); + state.requiredString(cursor.u32()); + if (cursor.u32() !== 0) + fail("non-zero-reserved", `non-zero reserved component bytes at index ${index}`); + for (let valueIndex = 0; valueIndex < 9; valueIndex += 1) + validateFinite(cursor.f64(), "component transform", index * 9 + valueIndex); + } + const anchorsOffset = cursor.offset; + for (let index = 0; index < anchorCount; index += 1) { + state.unique("anchor", index, state.requiredString(cursor.u32())); + state.optionalString(cursor.u32()); + validateFinite(cursor.f64(), "anchor coordinate", index * 2); + validateFinite(cursor.f64(), "anchor coordinate", index * 2 + 1); + } + const guidelinesOffset = cursor.offset; + for (let index = 0; index < guidelineCount; index += 1) { + state.unique("guideline", index, state.requiredString(cursor.u32())); + state.optionalString(cursor.u32()); + state.optionalString(cursor.u32()); + const flags = cursor.u32(); + if ((flags & ~7) !== 0) + fail("unknown-record-flags", `unknown guideline flags ${hex(flags, 8)} at index ${index}`); + for (const [valueIndex, bit] of [1, 2, 4].entries()) { + const offset = cursor.offset; + const value = cursor.f64(); + if ((flags & bit) === 0) + validateAbsent(view, offset, "guideline number", index * 3 + valueIndex); + else validateFinite(value, "guideline number", index * 3 + valueIndex); + } + } + const libOffset = cursor.offset; + const libEnd = checkedAdd(libOffset, libBytes); + requireRange(bytes, libOffset, libBytes); + cursor.end = libEnd; + decodeMap(cursor, state, 0); + const consumedLib = cursor.offset - libOffset; + if (consumedLib !== libBytes) + fail( + "lib-length-mismatch", + `layer lib length mismatch: expected ${libBytes} bytes, consumed ${consumedLib}`, + ); + if (libEnd !== bytes.byteLength) + fail( + "length-mismatch", + `glyph-layer payload length mismatch: expected ${libEnd} bytes, got ${bytes.byteLength}`, + ); + if (state.nextString !== stringCount) + fail( + "unreferenced-strings", + `string table has unreferenced entries beginning at index ${state.nextString}`, + ); + + return { + strings, + stringCount, + contourCount, + pointCount, + componentCount, + anchorCount, + guidelineCount, + idIndex, + sourceIdIndex, + width, + height, + contoursOffset, + componentsOffset, + anchorsOffset, + guidelinesOffset, + libOffset, + libEnd, + }; +} + +class StringPool { + readonly #indexes = new Map(); + readonly #values: string[] = []; + readonly #encoded: Uint8Array[] = []; + #byteLength = 0; + + get count(): number { + return this.#values.length; + } + get byteLength(): number { + return this.#byteLength; + } + get encodedValues(): readonly Uint8Array[] { + return this.#encoded; + } + + intern(value: string): number { + const existing = this.#indexes.get(value); + if (existing !== undefined) return existing; + checkLimit("string count", this.#values.length + 1, MAX_LAYER_STRING_COUNT); + const encoded = textEncoder.encode(value); + this.#byteLength = checkedAdd(this.#byteLength, encoded.byteLength); + checkLimit("string bytes", this.#byteLength, MAX_LAYER_STRING_BYTES); + const index = this.#values.length; + this.#values.push(value); + this.#encoded.push(encoded); + this.#indexes.set(value, index); + return index; + } + + index(value: string): number { + const index = this.#indexes.get(value); + if (index === undefined) throw new Error("strings must be gathered before encoding"); + return index; + } +} + +class DecodeState { + readonly #strings: readonly string[]; + readonly #validateReferences: boolean; + readonly #identities = new Map>(); + nextString = 0; + pointIndex = 0; + libValues = 0; + + constructor(strings: readonly string[], validateReferences: boolean) { + this.#strings = strings; + this.#validateReferences = validateReferences; + } + + reference(index: number): void { + if (index >= this.#strings.length) + fail("string-reference-out-of-range", `string reference ${index} is out of range`); + if (!this.#validateReferences) return; + if (index > this.nextString) + fail( + "noncanonical-string-reference", + `string reference ${index} is out of canonical first-use order; expected at most ${this.nextString}`, + ); + if (index === this.nextString) this.nextString += 1; + } + + requiredString(index: number): string { + this.reference(index); + return this.#strings[index]; + } + + optionalString(index: number): string | null { + return index === NONE_STRING ? null : this.requiredString(index); + } + + unique(kind: string, index: number, value: string): void { + let identities = this.#identities.get(kind); + if (!identities) { + identities = new Set(); + this.#identities.set(kind, identities); + } + if (identities.has(value)) + fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); + identities.add(value); + } + + countLibValue(): void { + this.libValues = checkedAdd(this.libValues, 1); + checkLimit("lib value count", this.libValues, MAX_LAYER_LIB_VALUES); + } +} + +class Cursor { + readonly #bytes: Uint8Array; + readonly #view: DataView; + offset: number; + end: number; + + constructor(bytes: Uint8Array, offset: number, end: number) { + this.#bytes = bytes; + this.#view = dataView(bytes); + this.offset = offset; + this.end = end; + } + + take(count: number): Uint8Array { + const next = checkedAdd(this.offset, count); + if (next > this.end) + fail( + "truncated", + `glyph-layer body is truncated at byte ${this.offset}: need ${count} bytes, have ${Math.max(0, this.end - this.offset)}`, + ); + const value = this.#bytes.subarray(this.offset, next); + this.offset = next; + return value; + } + + u8(): number { + const offset = this.offset; + this.take(1); + return this.#view.getUint8(offset); + } + u16(): number { + const offset = this.offset; + this.take(2); + return this.#view.getUint16(offset, true); + } + u32(): number { + const offset = this.offset; + this.take(4); + return this.#view.getUint32(offset, true); + } + i64(): bigint { + const offset = this.offset; + this.take(8); + return this.#view.getBigInt64(offset, true); + } + u64(): bigint { + const offset = this.offset; + this.take(8); + return this.#view.getBigUint64(offset, true); + } + f64(): number { + const offset = this.offset; + this.take(8); + return this.#view.getFloat64(offset, true); + } +} + +class Writer { + readonly #bytes: Uint8Array; + readonly #view: DataView; + offset: number; + + constructor(bytes: Uint8Array, offset: number) { + this.#bytes = bytes; + this.#view = dataView(bytes); + this.offset = offset; + } + + u8(value: number): void { + this.#view.setUint8(this.offset, value); + this.offset += 1; + } + u16(value: number): void { + this.#view.setUint16(this.offset, value, true); + this.offset += 2; + } + u32(value: number): void { + this.#view.setUint32(this.offset, value, true); + this.offset += 4; + } + i64(value: bigint): void { + checkInteger(value, MIN_I64, MAX_I64); + this.#view.setBigInt64(this.offset, value, true); + this.offset += 8; + } + u64(value: bigint): void { + checkInteger(value, 0n, MAX_U64); + this.#view.setBigUint64(this.offset, value, true); + this.offset += 8; + } + f64(value: number): void { + this.#view.setFloat64(this.offset, value, true); + this.offset += 8; + } + bytes(value: Uint8Array): void { + this.#bytes.set(value, this.offset); + this.offset += value.byteLength; + } +} + +function decodeMap( + cursor: Cursor, + state: DecodeState, + depth: number, +): ReadonlyMap { + checkDepth(depth); + const count = cursor.u32(); + checkLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); + const values = new Map(); + let previous: string | null = null; + for (let index = 0; index < count; index += 1) { + const keyOffset = cursor.offset; + const key = state.requiredString(cursor.u32()); + if (previous !== null && compareUtf8(previous, key) >= 0) + fail( + "noncanonical-map-order", + `dictionary keys are not in canonical UTF-8 order at byte ${keyOffset}`, + ); + previous = key; + values.set(key, decodeLibValue(cursor, state, depth + 1)); + } + return values; +} + +function decodeLibValue(cursor: Cursor, state: DecodeState, depth: number): LayerLibValue { + checkDepth(depth); + state.countLibValue(); + const tagOffset = cursor.offset; + const tag = cursor.u8(); + if (cursor.u8() !== 0 || cursor.u16() !== 0) + fail("non-zero-reserved", `non-zero reserved lib value bytes at index ${state.libValues - 1}`); + switch (tag) { + case 0: + return { kind: "string", value: state.requiredString(cursor.u32()) }; + case 1: + return { kind: "integer", value: cursor.i64() }; + case 2: + return { kind: "unsigned-integer", value: cursor.u64() }; + case 3: { + const value = cursor.f64(); + validateFinite(value, "lib float", state.libValues - 1); + return { kind: "float", value }; + } + case 4: { + const offset = cursor.offset; + const value = cursor.u8(); + if (cursor.take(3).some((byte) => byte !== 0)) + fail( + "non-zero-reserved", + `non-zero reserved lib boolean bytes at index ${state.libValues - 1}`, + ); + if (value !== 0 && value !== 1) + fail("invalid-boolean", `invalid boolean ${value} at byte ${offset}`); + return { kind: "boolean", value: value === 1 }; + } + case 5: { + const count = cursor.u32(); + checkLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); + const value: LayerLibValue[] = []; + for (let index = 0; index < count; index += 1) + value.push(decodeLibValue(cursor, state, depth + 1)); + return { kind: "array", value }; + } + case 6: + return { kind: "dict", value: decodeMap(cursor, state, depth + 1) }; + case 7: + return { kind: "data", value: Uint8Array.from(cursor.take(cursor.u32())) }; + case 8: + return { kind: "date", value: state.requiredString(cursor.u32()) }; + case 9: + return { kind: "uid", value: cursor.u64() }; + default: + fail("unknown-lib-tag", `unknown lib value tag ${hex(tag)} at byte ${tagOffset}`); + } +} + +function gatherMapStrings( + values: ReadonlyMap, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkDepth(depth); + for (const [key, value] of sortedEntries(values)) { + strings.intern(key); + gatherValueStrings(value, strings, depth + 1, state); + } +} + +function gatherValueStrings( + value: LayerLibValue, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + switch (value.kind) { + case "string": + case "date": + strings.intern(value.value); + break; + case "array": + for (const item of value.value) gatherValueStrings(item, strings, depth + 1, state); + break; + case "dict": + gatherMapStrings(value.value, strings, depth + 1, state); + break; + } +} + +function mapByteLength( + values: ReadonlyMap, + depth: number, + state: { count: number }, +): number { + checkDepth(depth); + let length = 4; + for (const [, value] of sortedEntries(values)) + length = checkedAdd(length, checkedAdd(4, valueByteLength(value, depth + 1, state))); + return length; +} + +function valueByteLength(value: LayerLibValue, depth: number, state: { count: number }): number { + checkDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + switch (value.kind) { + case "string": + case "date": + return 8; + case "integer": + checkInteger(value.value, MIN_I64, MAX_I64); + return 12; + case "unsigned-integer": + case "uid": + checkInteger(value.value, 0n, MAX_U64); + return 12; + case "float": + return 12; + case "boolean": + return 8; + case "array": + return value.value.reduce( + (length, item) => checkedAdd(length, valueByteLength(item, depth + 1, state)), + 8, + ); + case "dict": + return checkedAdd(4, mapByteLength(value.value, depth + 1, state)); + case "data": + return checkedAdd(8, value.value.byteLength); + } +} + +function encodeMap( + writer: Writer, + values: ReadonlyMap, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkDepth(depth); + writer.u32(values.size); + for (const [key, value] of sortedEntries(values)) { + writer.u32(strings.index(key)); + encodeLibValue(writer, value, strings, depth + 1, state); + } +} + +function encodeLibValue( + writer: Writer, + value: LayerLibValue, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + const tags: Record = { + string: 0, + integer: 1, + "unsigned-integer": 2, + float: 3, + boolean: 4, + array: 5, + dict: 6, + data: 7, + date: 8, + uid: 9, + }; + writer.u8(tags[value.kind]); + writer.u8(0); + writer.u16(0); + switch (value.kind) { + case "string": + case "date": + writer.u32(strings.index(value.value)); + break; + case "integer": + writer.i64(value.value); + break; + case "unsigned-integer": + case "uid": + writer.u64(value.value); + break; + case "float": + writer.f64(value.value); + break; + case "boolean": + writer.u8(value.value ? 1 : 0); + writer.u8(0); + writer.u16(0); + break; + case "array": + writer.u32(value.value.length); + for (const item of value.value) encodeLibValue(writer, item, strings, depth + 1, state); + break; + case "dict": + encodeMap(writer, value.value, strings, depth + 1, state); + break; + case "data": + writer.u32(value.value.byteLength); + writer.bytes(value.value); + break; + } +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +function validateStrings( + bytes: Uint8Array, + count: number, + expectedBytes: number, + valuesOffset: number, +): readonly string[] { + const view = dataView(bytes); + if (view.getUint32(HEADER_LENGTH, true) !== 0) + fail("invalid-string-offsets", "non-canonical string offsets at index 0"); + const strings: string[] = []; + const seen = new Set(); + let previous = 0; + for (let index = 0; index < count; index += 1) { + const end = view.getUint32(HEADER_LENGTH + (index + 1) * 4, true); + if (end < previous || end > expectedBytes) + fail("invalid-string-offsets", `non-canonical string offsets at index ${index + 1}`); + let value: string; + try { + value = textDecoder.decode(bytes.subarray(valuesOffset + previous, valuesOffset + end)); + } catch { + fail("invalid-utf8", `invalid UTF-8 in string ${index}`); + } + if (seen.has(value)) fail("duplicate-string", `duplicate string-table value at index ${index}`); + seen.add(value); + strings.push(value); + previous = end; + } + if (previous !== expectedBytes) + fail("invalid-string-offsets", `non-canonical string offsets at index ${count}`); + return strings; +} + +function sortedEntries( + values: ReadonlyMap, +): ReadonlyArray { + return [...values.entries()].sort((left, right) => compareUtf8(left[0], right[0])); +} + +function compareUtf8(left: string, right: string): number { + const leftBytes = textEncoder.encode(left); + const rightBytes = textEncoder.encode(right); + const count = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < count; index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; + } + return leftBytes.length - rightBytes.length; +} + +function transformValues(transform: LayerTransform): readonly number[] { + return [ + transform.translateX, + transform.translateY, + transform.rotation, + transform.scaleX, + transform.scaleY, + transform.skewX, + transform.skewY, + transform.centerX, + transform.centerY, + ]; +} + +function readTransform(view: DataView, offset: number): LayerTransform { + const values = Array.from({ length: 9 }, (_, index) => view.getFloat64(offset + index * 8, true)); + return { + translateX: values[0], + translateY: values[1], + rotation: values[2], + scaleX: values[3], + scaleY: values[4], + skewX: values[5], + skewY: values[6], + centerX: values[7], + centerY: values[8], + }; +} + +function pointTypeByte(type: LayerPointType): number { + switch (type) { + case "on-curve": + return 0; + case "off-curve": + return 1; + case "qcurve": + return 2; + } +} + +function pointTypeFromByte(value: number, index: number): LayerPointType { + switch (value) { + case 0: + return "on-curve"; + case 1: + return "off-curve"; + case 2: + return "qcurve"; + default: + fail("unknown-point-type", `unknown point type ${hex(value)} at index ${index}`); + } +} + +function optionalString(strings: readonly string[], index: number): string | null { + return index === NONE_STRING ? null : strings[index]; +} + +function validateFinite(value: number, field: string, index: number): void { + if (!Number.isFinite(value)) + fail("non-finite-number", `non-finite ${field} value at index ${index}`); +} + +function validateAbsent(view: DataView, offset: number, field: string, index: number): void { + if (view.getBigUint64(offset, true) !== 0n) + fail( + "noncanonical-absent-number", + `absent ${field} value at index ${index} is not canonical positive zero`, + ); +} + +function checkInteger(value: bigint, minimum: bigint, maximum: bigint): void { + if (value < minimum || value > maximum) + fail("integer-out-of-range", `integer ${value} is outside the encoded range`); +} + +function checkDepth(depth: number): void { + if (depth > MAX_LAYER_LIB_DEPTH) + fail( + "nesting-limit-exceeded", + `layer lib nesting exceeds implementation limit ${MAX_LAYER_LIB_DEPTH}`, + ); +} + +function checkLimit(field: string, actual: number, limit: number): void { + if (actual > limit) + fail( + "limit-exceeded", + `glyph-layer ${field} exceeds implementation limit ${limit}: got ${actual}`, + ); +} + +function requireRange(bytes: Uint8Array, offset: number, count: number): void { + const end = checkedAdd(offset, count); + if (end > bytes.byteLength) + fail( + "truncated", + `glyph-layer body is truncated at byte ${offset}: need ${count} bytes, have ${Math.max(0, bytes.byteLength - offset)}`, + ); +} + +function checkedAdd(left: number, right: number): number { + const value = left + right; + if (!Number.isSafeInteger(value)) + fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); + return value; +} + +function checkedMultiply(left: number, right: number): number { + const value = left * right; + if (!Number.isSafeInteger(value)) + fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); + return value; +} + +function dataView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function hex(value: number, width = 2): string { + return `0x${value.toString(16).padStart(width, "0")}`; +} diff --git a/packages/glyph-codec/src/outline.ts b/packages/glyph-codec/src/outline.ts index d3e90dc9..b5486a2c 100644 --- a/packages/glyph-codec/src/outline.ts +++ b/packages/glyph-codec/src/outline.ts @@ -1,4 +1,7 @@ -import type { GlyphCodecErrorCode, OutlineCommand } from "./types"; +import { fail } from "./error"; +import type { OutlineCommand } from "./types"; + +export { GlyphCodecError } from "./error"; const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; const OUTLINE_KIND = 0x01; @@ -9,16 +12,6 @@ export const MAX_COMMAND_COUNT = 1_000_000; export const MAX_COORDINATE_COUNT = 6_000_000; export const MAX_PAYLOAD_BYTES = 32 * 1024 * 1024; -export class GlyphCodecError extends Error { - readonly code: GlyphCodecErrorCode; - - constructor(code: GlyphCodecErrorCode, message: string) { - super(message); - this.name = "GlyphCodecError"; - this.code = code; - } -} - type Header = { readonly commandCount: number; readonly coordinateCount: number; @@ -394,7 +387,3 @@ function checkLimit(field: string, actual: number, limit: number): void { function hex(value: number, width = 2): string { return `0x${value.toString(16).padStart(width, "0")}`; } - -function fail(code: GlyphCodecErrorCode, message: string): never { - throw new GlyphCodecError(code, message); -} diff --git a/packages/glyph-codec/src/types.ts b/packages/glyph-codec/src/types.ts index 635a899c..a17a2d8f 100644 --- a/packages/glyph-codec/src/types.ts +++ b/packages/glyph-codec/src/types.ts @@ -19,19 +19,115 @@ export type OutlineCommand = } | { readonly kind: "close" }; +export type LayerPointType = "on-curve" | "off-curve" | "qcurve"; + +export type LayerPoint = { + readonly id: string; + readonly type: LayerPointType; + readonly smooth: boolean; + readonly x: number; + readonly y: number; +}; + +export type LayerContour = { + readonly id: string; + readonly closed: boolean; + readonly points: readonly LayerPoint[]; +}; + +export type LayerTransform = { + readonly translateX: number; + readonly translateY: number; + readonly rotation: number; + readonly scaleX: number; + readonly scaleY: number; + readonly skewX: number; + readonly skewY: number; + readonly centerX: number; + readonly centerY: number; +}; + +export type LayerComponent = { + readonly id: string; + readonly baseGlyphId: string; + readonly baseGlyphName: string; + readonly transform: LayerTransform; +}; + +export type LayerAnchor = { + readonly id: string; + readonly name: string | null; + readonly x: number; + readonly y: number; +}; + +export type LayerGuideline = { + readonly id: string; + readonly x: number | null; + readonly y: number | null; + readonly angle: number | null; + readonly name: string | null; + readonly color: string | null; +}; + +export type LayerLibValue = + | { readonly kind: "string"; readonly value: string } + | { readonly kind: "integer"; readonly value: bigint } + | { readonly kind: "unsigned-integer"; readonly value: bigint } + | { readonly kind: "float"; readonly value: number } + | { readonly kind: "boolean"; readonly value: boolean } + | { readonly kind: "array"; readonly value: readonly LayerLibValue[] } + | { readonly kind: "dict"; readonly value: ReadonlyMap } + | { readonly kind: "data"; readonly value: Uint8Array } + | { readonly kind: "date"; readonly value: string } + | { readonly kind: "uid"; readonly value: bigint }; + +export type GlyphLayer = { + readonly id: string; + readonly sourceId: string; + readonly width: number; + readonly height: number | null; + readonly contours: readonly LayerContour[]; + readonly components: readonly LayerComponent[]; + readonly anchors: readonly LayerAnchor[]; + readonly guidelines: readonly LayerGuideline[]; + readonly lib: ReadonlyMap; +}; + export type GlyphCodecErrorCode = | "header-truncated" | "wrong-magic" | "unsupported-kind" | "unsupported-version" | "unknown-flags" + | "unknown-layer-flags" + | "unknown-record-flags" + | "non-zero-reserved" | "unknown-command" + | "unknown-point-type" + | "unknown-lib-tag" + | "invalid-boolean" + | "invalid-utf8" + | "invalid-string-offsets" + | "duplicate-string" + | "string-reference-out-of-range" + | "noncanonical-string-reference" + | "unreferenced-strings" + | "duplicate-identity" + | "noncanonical-map-order" | "invalid-command-order" | "coordinate-count-mismatch" + | "point-count-mismatch" + | "lib-length-mismatch" | "non-zero-padding" | "non-finite-coordinate" | "non-finite-input-coordinate" | "coordinate-out-of-f32-range" + | "non-finite-number" + | "noncanonical-absent-number" + | "integer-out-of-range" + | "nesting-limit-exceeded" + | "truncated" | "length-overflow" | "length-mismatch" | "limit-exceeded"; From 81dc3dcf33aaa18826129b6c1723849f6f4efe9d Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 27 Jul 2026 08:10:47 +0000 Subject: [PATCH 02/29] Store glyph layers as lazy SQLite blobs --- Cargo.lock | 1 + crates/shift-bridge/docs/DOCS.md | 6 +- crates/shift-bridge/src/bridge.rs | 89 ++- crates/shift-font/src/error.rs | 7 + crates/shift-font/src/ir/font.rs | 36 + crates/shift-store/Cargo.toml | 1 + crates/shift-store/README.md | 61 +- crates/shift-store/src/change_set.rs | 656 +++++----------- crates/shift-store/src/component.rs | 175 ++--- crates/shift-store/src/error.rs | 15 + crates/shift-store/src/font_state.rs | 372 +++------ crates/shift-store/src/layer.rs | 31 +- crates/shift-store/src/lib.rs | 4 + crates/shift-store/src/outline.rs | 130 ++-- crates/shift-store/src/packed_layer.rs | 725 ++++++++++++++++++ crates/shift-store/src/schema.rs | 82 +- crates/shift-store/src/stream_writer.rs | 34 + crates/shift-store/tests/store_test.rs | 10 +- crates/shift-workspace/docs/DOCS.md | 6 +- crates/shift-workspace/src/workspace.rs | 152 +++- .../shift-workspace/tests/workspace_test.rs | 34 + docs/architecture/index.md | 1 + 22 files changed, 1565 insertions(+), 1063 deletions(-) create mode 100644 crates/shift-store/src/packed_layer.rs create mode 100644 crates/shift-store/src/stream_writer.rs diff --git a/Cargo.lock b/Cargo.lock index 1941d3d6..3d712c88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ dependencies = [ "rusqlite", "serde_json", "shift-font", + "shift-glyph-codec", "tempfile", "thiserror", ] diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index ed08f1b2..02b30aaa 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -12,7 +12,9 @@ NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bri **Architecture Invariant:** Bulk position updates use `Float64Array` values plus typed node descriptors. **WHY:** Drag updates keep the hot path in flat numeric buffers while IDs remain branded strings at the API boundary. -**Architecture Invariant:** Export uses a clone/COW `FontSaveSnapshot` of the current workspace font. **WHY:** Async export can run from a stable view without coupling output generation to renderer focus state. +**Architecture Invariant:** Export explicitly acquires all persisted layers before taking a clone/COW `FontSaveSnapshot`. **WHY:** Async export gets a complete stable view while ordinary workspace open remains directory-first and lazy. + +**Architecture Invariant:** Glyph snapshot, projection, and preview methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans. **Architecture Invariant:** `shift-font` constructs typed glyph and source-metric interpolation; renderer code only evaluates their flattened transport snapshots. **WHY:** Per-location canvas work stays cheap without moving variation-model construction or value-layout ownership into transport code. @@ -57,7 +59,7 @@ crates/shift-bridge/ 2. JS calls a mutation such as `closeContour(layerId, contourId)`. 3. `Bridge` parses boundary strings and asks `FontWorkspace` to run the edit against that layer. 4. The bridge returns a `shift-wire` change DTO and bumps the live version. -5. Full glyph snapshots include authored layer state plus the same `GlyphProjection` used by lightweight reads. `getGlyphProjections()` batches roots and transitive component projections without resolving a design location. Source reads expose master sources only; layer-only/background sources remain native authoring details and never enter renderer interpolation. +5. Full glyph snapshots first acquire requested layer payloads, then include authored state plus the same `GlyphProjection` used by lightweight reads. `getGlyphProjections()` and previews expand transitive component identities through SQLite indexes, acquire those layers, and only then project without further I/O. Source reads expose master sources only; layer-only/background sources remain native authoring details and never enter renderer interpolation. 6. `saveWorkspace()` / `saveWorkspaceAs(path)` update the `.shift` source package target and record the persisted version. 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. 8. `closeWorkspace()` drops the live Rust workspace handle before the utility process deletes a clean or discarded SQLite document. diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index e4dbf204..305c1fae 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -623,8 +623,8 @@ impl Bridge { .map(|touched| touched.layer.id()) .collect(); let dependents = self - .font()? - .dependents_of_layers(&touched_layer_ids) + .workspace()? + .dependent_glyph_ids_for_layers(&touched_layer_ids)? .into_iter() .map(|name| name.to_string()) .collect(); @@ -714,14 +714,22 @@ impl Bridge { /// Glyph-addressed snapshots for renderer-local synchronous font state. #[napi] pub fn get_glyph_snapshots( - &self, + &mut self, requests: Vec, ) -> errors::Result> { + let requests = requests + .into_iter() + .map(GlyphSnapshotRequest::from) + .collect::>(); + let glyph_ids = requests + .iter() + .map(|request| request.glyph_id.clone()) + .collect::>(); + self.workspace_mut()?.acquire_glyphs(&glyph_ids, false)?; + let font = self.font()?; let mut snapshots = Vec::new(); - for request in requests { - let request = GlyphSnapshotRequest::from(request); let glyph_id = request.glyph_id; let Some(glyph) = font.glyph(glyph_id.clone()) else { continue; @@ -757,15 +765,18 @@ impl Bridge { /// renderer can evaluate design-location changes without further IPC. #[napi(ts_args_type = "glyphIds: Array")] pub fn get_glyph_projections( - &self, + &mut self, glyph_ids: Vec, ) -> errors::Result> { - let font = self.font()?; - let mut projections = Vec::new(); - let mut pending = glyph_ids + let glyph_ids = glyph_ids .iter() .map(|glyph_id| parse::(glyph_id)) - .collect::>>()?; + .collect::>>()?; + self.workspace_mut()?.acquire_glyphs(&glyph_ids, true)?; + + let font = self.font()?; + let mut projections = Vec::new(); + let mut pending = glyph_ids.into_iter().collect::>(); let mut seen = HashSet::new(); while let Some(glyph_id) = pending.pop_front() { @@ -796,17 +807,18 @@ impl Bridge { /// `get_glyph_snapshots`. #[napi(ts_args_type = "glyphIds: Array, location: NapiLocation")] pub fn get_glyph_previews( - &self, + &mut self, glyph_ids: Vec, location: NapiLocation, ) -> errors::Result> { - let font = self.font()?; let glyph_ids = glyph_ids .iter() .map(|glyph_id| parse::(glyph_id)) .collect::>>()?; let location = map_location(location)?; + self.workspace_mut()?.acquire_glyphs(&glyph_ids, true)?; + let font = self.font()?; let mut projection = font.projection(&location); let previews = projection .glyphs(&glyph_ids)? @@ -1053,7 +1065,8 @@ impl Bridge { ) } - fn save_snapshot(&self) -> BridgeResult { + fn save_snapshot(&mut self) -> BridgeResult { + self.workspace_mut()?.acquire_all_layers()?; Ok(FontSaveSnapshot::new(self.font()?.clone(), None)) } @@ -2025,7 +2038,7 @@ mod tests { assert!(result.is_err()); // atomicity: the valid point from the rejected set must NOT exist - let state = glyph_state(&bridge, "A"); + let state = glyph_state(&mut bridge, "A"); assert_eq!(state.structure.contours[0].points.len(), 1); } @@ -2159,7 +2172,7 @@ mod tests { None, ); assert!(result.is_err()); - let state = glyph_state(&bridge, "A"); + let state = glyph_state(&mut bridge, "A"); assert_eq!(state.structure.contours[0].points.len(), 1); assert_eq!(state.structure.anchors.len(), 1); @@ -2186,7 +2199,7 @@ mod tests { ) .unwrap(); assert_eq!(removed.layers.len(), 1); - let state = glyph_state(&bridge, "A"); + let state = glyph_state(&mut bridge, "A"); assert_eq!(state.structure.contours[0].points.len(), 0); assert!(state.structure.anchors.is_empty()); } @@ -2206,15 +2219,15 @@ mod tests { Some("Add Anchor".to_string()), ) .unwrap(); - assert_eq!(glyph_state(&bridge, "A").structure.anchors.len(), 1); + assert_eq!(glyph_state(&mut bridge, "A").structure.anchors.len(), 1); let undone = bridge.undo().unwrap().expect("one entry to undo"); assert_eq!(undone.layers.len(), 1); - assert!(glyph_state(&bridge, "A").structure.anchors.is_empty()); + assert!(glyph_state(&mut bridge, "A").structure.anchors.is_empty()); let redone = bridge.redo().unwrap().expect("one entry to redo"); assert_eq!(redone.layers.len(), 1); - let state = glyph_state(&bridge, "A"); + let state = glyph_state(&mut bridge, "A"); assert_eq!(state.structure.anchors.len(), 1); assert_eq!(state.structure.anchors[0].id, a1); assert_eq!(state.structure.anchors[0].name.as_deref(), Some("top")); @@ -2251,10 +2264,10 @@ mod tests { assert!(result.is_err()); // atomicity: the valid anchor from the rejected set must NOT exist - assert_eq!(glyph_state(&bridge, "A").structure.anchors.len(), 1); + assert_eq!(glyph_state(&mut bridge, "A").structure.anchors.len(), 1); } - fn glyph_state(bridge: &Bridge, name: &str) -> NapiGlyphState { + fn glyph_state(bridge: &mut Bridge, name: &str) -> NapiGlyphState { let record = bridge .get_glyphs() .unwrap() @@ -2267,7 +2280,7 @@ mod tests { } fn glyph_source_state( - bridge: &Bridge, + bridge: &mut Bridge, glyph_id: &str, source_id: &str, ) -> Option { @@ -2289,7 +2302,7 @@ mod tests { .map(|layer| layer.state) } - fn contour_point_count(bridge: &Bridge) -> usize { + fn contour_point_count(bridge: &mut Bridge) -> usize { glyph_state(bridge, "A") .structure .contours @@ -2315,15 +2328,15 @@ mod tests { Some("Add Point".to_string()), ) .unwrap(); - assert_eq!(contour_point_count(&bridge), 1); + assert_eq!(contour_point_count(&mut bridge), 1); let undone = bridge.undo().unwrap().expect("one entry to undo"); assert_eq!(undone.layers.len(), 1); - assert_eq!(contour_point_count(&bridge), 0); + assert_eq!(contour_point_count(&mut bridge), 0); let redone = bridge.redo().unwrap().expect("one entry to redo"); assert_eq!(redone.layers.len(), 1); - assert_eq!(contour_point_count(&bridge), 1); + assert_eq!(contour_point_count(&mut bridge), 1); } #[test] @@ -2368,7 +2381,7 @@ mod tests { .unwrap(); assert!(bridge.redo().unwrap().is_none()); - assert_eq!(contour_point_count(&bridge), 1); + assert_eq!(contour_point_count(&mut bridge), 1); } #[test] @@ -2447,13 +2460,13 @@ mod tests { None, ) .unwrap(); - assert_eq!(contour_point_count(&bridge), 2); + assert_eq!(contour_point_count(&mut bridge), 2); bridge .undo() .unwrap() .expect("removePoints must be undoable"); - assert_eq!(contour_point_count(&bridge), 3); + assert_eq!(contour_point_count(&mut bridge), 3); } fn test_paths(label: &str) -> (String, String) { @@ -2856,7 +2869,7 @@ mod tests { ) .unwrap(); - let state = glyph_source_state(&bridge, &glyph_id, "source_bold") + let state = glyph_source_state(&mut bridge, &glyph_id, "source_bold") .expect("the explicit layer must resolve by glyph and source"); assert_eq!(state.layer_id, layer_id); assert_eq!(applied.layers[0].layer_id, layer_id); @@ -2919,9 +2932,10 @@ mod tests { ) .unwrap(); - let source = glyph_source_state(&bridge, &glyph_id, &default_source_id(&bridge)) + let source_id = default_source_id(&bridge); + let source = glyph_source_state(&mut bridge, &glyph_id, &source_id) .expect("source layer should be readable"); - let cloned = glyph_source_state(&bridge, &glyph_id, "source_bold") + let cloned = glyph_source_state(&mut bridge, &glyph_id, "source_bold") .expect("cloned layer should be readable"); assert_eq!(applied.layers[0].layer_id, layer_id); @@ -3040,7 +3054,7 @@ mod tests { None, ) .unwrap(); - assert!(glyph_source_state(&bridge, &glyph_id, "source_bold").is_some()); + assert!(glyph_source_state(&mut bridge, &glyph_id, "source_bold").is_some()); let applied = bridge .apply(vec![delete_source_intent("source_bold")], None) @@ -3057,8 +3071,9 @@ mod tests { .expect("deleteSource layer removal must echo glyph records"); assert_eq!(glyphs[0].layers.len(), 1); assert!(applied.layers.is_empty()); - assert!(glyph_source_state(&bridge, &glyph_id, "source_bold").is_none()); - let default_state = glyph_source_state(&bridge, &glyph_id, &default_source_id(&bridge)) + assert!(glyph_source_state(&mut bridge, &glyph_id, "source_bold").is_none()); + let source_id = default_source_id(&bridge); + let default_state = glyph_source_state(&mut bridge, &glyph_id, &source_id) .expect("default source must keep its layer"); assert_eq!(default_state.layer_id, default_layer_id); } @@ -3246,7 +3261,7 @@ mod tests { ) .unwrap(); - let state = glyph_state(&bridge, "A"); + let state = glyph_state(&mut bridge, "A"); assert_eq!(state.layer_id, layer_id); assert_eq!(state.structure.contours.len(), 1); @@ -3256,7 +3271,7 @@ mod tests { #[test] fn get_glyph_snapshots_returns_none_for_missing_glyph() { - let bridge = bridge_with_workspace(); + let mut bridge = bridge_with_workspace(); let missing_glyph_id = shift_font::GlyphId::new().to_string(); let snapshots = bridge diff --git a/crates/shift-font/src/error.rs b/crates/shift-font/src/error.rs index 9367ddd7..eabe4328 100644 --- a/crates/shift-font/src/error.rs +++ b/crates/shift-font/src/error.rs @@ -79,6 +79,13 @@ pub enum CoreError { #[error("layer {0} not found")] LayerNotFound(LayerId), + #[error("layer {layer_id} source is {actual_source_id}, expected {expected_source_id}")] + LayerSourceMismatch { + layer_id: LayerId, + expected_source_id: SourceId, + actual_source_id: SourceId, + }, + #[error("layer {layer_id} belongs to glyph {actual_glyph_id}, not glyph {glyph_id}")] LayerGlyphMismatch { layer_id: LayerId, diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index 9c750b04..72e82ab8 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -1106,6 +1106,42 @@ impl Font { Ok(()) } + /// Replaces an existing layer with the same identity. Used by bounded + /// storage acquisition to hydrate a directory placeholder without + /// changing glyph/layer ownership. + pub fn replace_glyph_layer(&mut self, layer: GlyphLayer) -> CoreResult<()> { + let layer_id = layer.id(); + let glyph_id = self + .glyph_id_by_layer(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; + let previous = self + .layer(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id.clone()))? + .clone(); + if layer.source_id() != previous.source_id() { + return Err(CoreError::LayerSourceMismatch { + layer_id, + expected_source_id: previous.source_id(), + actual_source_id: layer.source_id(), + }); + } + + let mut next_index = self.index().clone(); + next_index.remove_layer(glyph_id.clone(), &previous); + next_index.validate_layer_insert(&glyph_id, &layer)?; + next_index.insert_layer(glyph_id.clone(), &layer); + + let state = self.state_mut(); + let glyph = state + .data + .glyphs + .get_mut(&glyph_id) + .ok_or(CoreError::GlyphNotFound(glyph_id))?; + Arc::make_mut(glyph).set_layer(layer); + state.index = next_index; + Ok(()) + } + pub fn remove_glyph_layer(&mut self, layer_id: LayerId) -> CoreResult { let glyph_id = self .glyph_id_by_layer(layer_id.clone()) diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml index 08da32a1..0fcc7ffe 100644 --- a/crates/shift-store/Cargo.toml +++ b/crates/shift-store/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] shift-font = { workspace = true } +shift-glyph-codec = { workspace = true } rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } serde_json = "1.0" thiserror = "2" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 504bcea9..c57407f5 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -1,40 +1,45 @@ # shift-store -`shift-store` owns Shift's durable source database. +`shift-store` owns Shift's durable SQLite working database. -The crate provides the storage boundary between the rest of the Rust application and SQLite. Callers should use typed store APIs exposed by this crate instead of preparing SQL statements or opening database connections directly. +Callers use typed APIs from this crate rather than preparing SQL or opening a second application-level persistence path. -## Responsibilities +## Storage boundary + +- Font metadata and the glyph/layer directory remain relational and load eagerly. +- Each editable glyph layer is one independently addressable `shift.glyph-layer.v1` BLOB. +- Component dependency rows contain only the earned query index: component ID, owner layer ID, base glyph ID, and ordinal. +- Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. +- Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. + +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload and cross-checks its directory and reference-index facts. `FontWorkspace` uses those APIs for explicit glyph acquisition and safe cache eviction. + +## Import boundary -- open and configure the SQLite connection; -- create and update the database schema; -- expose typed Rust APIs for source-store reads and writes; -- keep raw SQL local to this crate; -- preserve stable internal IDs for source entities; -- separate canonical source data from derived/index data as the store grows. +`LayerStreamWriter` accepts one parsed `GlyphLayer` at a time. A successful call encodes, indexes, commits, and releases that layer before the caller supplies the next one; no complete in-memory `Font` is required. Format-specific bounded parsers, progress/cancellation, and atomic staging installation belong to the foreign-import layer. -## Design Goals +## Schema policy -- incremental writes; -- durability and crash-safe recovery; -- stable IDs; -- performant reads and writes for large fonts; -- clear handling of CJK-scale glyph inventories; -- efficient queries for references to and from source entities; -- support for components, kerning, and feature-related source data; -- clear separation between canonical source data and derived/index data; -- import/export support through typed store APIs rather than ad hoc SQL access. +Shift has not shipped this working-store schema. Schema changes therefore update the version-1 baseline directly; there is intentionally no compatibility migration for earlier development databases. -## Shape +## Responsibilities + +- configure SQLite for WAL-backed transactional work; +- own the baseline schema and raw SQL; +- preserve stable authored IDs and exact values; +- provide directory-first and bounded payload APIs; +- keep canonical payloads and relational query indexes atomic; +- support CJK-scale directories without BLOB scans. + +## Main modules ```text src/ - connection.rs # opening and configuring SQLite - error.rs # store error type - glyph.rs # glyph persistence API - schema.rs # schema creation and versioning entry point - store.rs # ShiftStore connection owner - types.rs # storage-facing IDs and small value types + connection.rs # SQLite opening, WAL, limits, foreign keys + schema.rs # pre-release version-1 baseline + font_state.rs # eager metadata/directory and explicit full materialization + packed_layer.rs # bounded BLOB fetch/replace and reference-index checks + stream_writer.rs # one-layer-at-a-time import sink + change_set.rs # transactional workspace changes + workspace_state.rs ``` - -Tests live in `tests/store_test.rs` and should start as small persistence checks before broader integration tests are added. diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index f7ac53bb..1c36843a 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -1,16 +1,51 @@ +use std::collections::HashSet; + use rusqlite::{Transaction, params}; use shift_font as font; use crate::{ - ShiftStore, StoreError, source::SourceKind, workspace_state::mark_workspace_dirty_in_tx, + ShiftStore, StoreError, + packed_layer::{create_empty_layer_in_tx, rewrite_layer_in_tx, write_layer_in_tx}, + source::SourceKind, + workspace_state::mark_workspace_dirty_in_tx, }; impl ShiftStore { pub fn apply_change_set(&mut self, change_set: &font::FontChangeSet) -> Result<(), StoreError> { + self.apply_change_set_inner(change_set, None) + } + + /// Applies relational changes and takes touched layer payloads from the + /// committed post-edit font. This preserves complete clone/materialize + /// results even when a compact change record only carries directory facts. + pub fn apply_change_set_with_font( + &mut self, + change_set: &font::FontChangeSet, + post_font: &font::Font, + ) -> Result<(), StoreError> { + self.apply_change_set_inner(change_set, Some(post_font)) + } + + fn apply_change_set_inner( + &mut self, + change_set: &font::FontChangeSet, + post_font: Option<&font::Font>, + ) -> Result<(), StoreError> { let tx = self.conn.transaction()?; + let mut touched_layer_ids = HashSet::new(); for change in &change_set.changes { apply_change(&tx, change)?; + if let Some(layer_id) = changed_layer_id(change) { + touched_layer_ids.insert(layer_id.clone()); + } + } + if let Some(post_font) = post_font { + for layer_id in touched_layer_ids { + if let Some(layer) = post_font.layer(layer_id) { + rewrite_layer_in_tx(&tx, layer)?; + } + } } mark_workspace_dirty_in_tx(&tx)?; @@ -21,7 +56,6 @@ impl ShiftStore { pub fn replace_font_state(&mut self, font: &font::Font) -> Result<(), StoreError> { let tx = self.conn.transaction()?; - tx.execute("DELETE FROM glyph_layer_lib", [])?; tx.execute("DELETE FROM glyph_lib", [])?; tx.execute("DELETE FROM font_lib", [])?; tx.execute("DELETE FROM fontinfo_remainder", [])?; @@ -30,12 +64,9 @@ impl ShiftStore { tx.execute("DELETE FROM kerning_group_members", [])?; tx.execute("DELETE FROM kerning_groups", [])?; tx.execute("DELETE FROM feature_text", [])?; - tx.execute("DELETE FROM glyph_layer_guidelines", [])?; tx.execute("DELETE FROM font_guidelines", [])?; - tx.execute("DELETE FROM glyph_layer_points", [])?; - tx.execute("DELETE FROM glyph_layer_contours", [])?; - tx.execute("DELETE FROM glyph_layer_anchors", [])?; tx.execute("DELETE FROM glyph_components", [])?; + tx.execute("DELETE FROM glyph_layer_payloads", [])?; tx.execute("DELETE FROM glyph_layers", [])?; tx.execute("DELETE FROM glyph_unicodes", [])?; tx.execute("DELETE FROM glyphs", [])?; @@ -116,16 +147,7 @@ impl ShiftStore { )?; for layer in glyph.layers().values().map(|layer| layer.as_ref()) { - upsert_layer( - &tx, - &layer.id(), - &glyph.id(), - &layer.source_id(), - Some(glyph.glyph_name()), - layer.width(), - layer.height(), - )?; - replace_full_layer_state(&tx, layer)?; + write_layer_in_tx(&tx, &glyph.id(), Some(glyph.glyph_name()), layer)?; } } @@ -254,12 +276,12 @@ fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), S upsert_glyph(tx, &change.glyph_id, &change.to_name, 0)?; replace_glyph_unicodes(tx, &change.glyph_id, &change.to_unicodes) } - font::FontChange::GlyphLayerCreated(change) => upsert_layer( + font::FontChange::GlyphLayerCreated(change) => create_empty_layer_in_tx( tx, - &change.layer_id, &change.glyph_id, - &change.source_id, change.name.as_ref(), + change.layer_id.clone(), + change.source_id.clone(), change.width, change.height, ), @@ -272,92 +294,170 @@ fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), S Ok(()) } font::FontChange::LayerMetricsChanged(change) => { - let rows_changed = tx.execute( - " - UPDATE glyph_layers - SET width = ?2, height = ?3 - WHERE id = ?1 - ", - params![layer_row_id(&change.layer_id), change.width, change.height,], - )?; - require_changed(rows_changed, "glyph layer", layer_row_id(&change.layer_id))?; - Ok(()) + update_packed_layer(tx, &change.layer_id, |layer| { + layer.set_width(change.width); + layer.set_height(change.height); + Ok(()) + }) } font::FontChange::ContourAdded(change) => { - require_layer_exists(tx, &change.layer_id)?; - replace_contour(tx, &change.layer_id, &change.contour) + update_packed_layer(tx, &change.layer_id, |layer| { + layer.add_contour(contour_from_value(&change.contour)); + Ok(()) + }) } font::FontChange::ContourOpenClosedChanged(change) => { - require_layer_exists(tx, &change.layer_id)?; - let rows_changed = tx.execute( - " - UPDATE glyph_layer_contours - SET closed = ?2 - WHERE id = ?1 - ", - params![change.contour_id.to_string(), change.closed], - )?; - require_changed(rows_changed, "contour", change.contour_id.to_string())?; - Ok(()) + update_packed_layer(tx, &change.layer_id, |layer| { + let contour = layer + .contour_mut(change.contour_id.clone()) + .ok_or_else(|| StoreError::MissingEntity { + kind: "contour", + id: change.contour_id.to_string(), + })?; + if change.closed { + contour.close(); + } else { + contour.open(); + } + Ok(()) + }) } font::FontChange::PointsAdded(change) => { - require_layer_exists(tx, &change.layer_id)?; - replace_contour(tx, &change.layer_id, &change.contour) + update_packed_layer(tx, &change.layer_id, |layer| { + layer.add_contour(contour_from_value(&change.contour)); + Ok(()) + }) } font::FontChange::PointsDeleted(change) => { - require_layer_exists(tx, &change.layer_id)?; - replace_contour(tx, &change.layer_id, &change.contour) + update_packed_layer(tx, &change.layer_id, |layer| { + layer.add_contour(contour_from_value(&change.contour)); + Ok(()) + }) } font::FontChange::PointSmoothChanged(change) => { - require_layer_exists(tx, &change.layer_id)?; - let rows_changed = tx.execute( - " - UPDATE glyph_layer_points - SET smooth = ?2 - WHERE id = ?1 - ", - params![change.point_id.to_string(), change.smooth], - )?; - require_changed(rows_changed, "point", change.point_id.to_string())?; - Ok(()) + update_packed_layer(tx, &change.layer_id, |layer| { + let point = layer + .contours_iter_mut() + .flat_map(|contour| contour.points_mut()) + .find(|point| point.id() == change.point_id) + .ok_or_else(|| StoreError::MissingEntity { + kind: "point", + id: change.point_id.to_string(), + })?; + point.set_smooth(change.smooth); + Ok(()) + }) } font::FontChange::PointPositionsChanged(change) => { - require_layer_exists(tx, &change.layer_id)?; - for point in &change.points { - let rows_changed = tx.execute( - " - UPDATE glyph_layer_points - SET x = ?2, y = ?3 - WHERE id = ?1 - ", - params![point.point_id.to_string(), point.x, point.y], - )?; - require_changed(rows_changed, "point", point.point_id.to_string())?; - } - Ok(()) + update_packed_layer(tx, &change.layer_id, |layer| { + for position in &change.points { + let point = layer + .contours_iter_mut() + .flat_map(|contour| contour.points_mut()) + .find(|point| point.id() == position.point_id) + .ok_or_else(|| StoreError::MissingEntity { + kind: "point", + id: position.point_id.to_string(), + })?; + point.set_position(position.x, position.y); + } + Ok(()) + }) } font::FontChange::AnchorPositionsChanged(change) => { - require_layer_exists(tx, &change.layer_id)?; - for anchor in &change.anchors { - let rows_changed = tx.execute( - " - UPDATE glyph_layer_anchors - SET x = ?2, y = ?3 - WHERE id = ?1 - ", - params![anchor.anchor_id.to_string(), anchor.x, anchor.y], - )?; - require_changed(rows_changed, "anchor", anchor.anchor_id.to_string())?; - } - Ok(()) + update_packed_layer(tx, &change.layer_id, |layer| { + for position in &change.anchors { + if !layer.set_anchor_position( + position.anchor_id.clone(), + position.x, + position.y, + ) { + return Err(StoreError::MissingEntity { + kind: "anchor", + id: position.anchor_id.to_string(), + }); + } + } + Ok(()) + }) } font::FontChange::LayerGeometryReplaced(change) => { - require_layer_exists(tx, &change.layer_id)?; - replace_layer_geometry(tx, &change.layer_id, &change.layer) + update_packed_layer(tx, &change.layer_id, |layer| { + layer.set_width(change.layer.width); + layer.set_height(change.layer.height); + layer.clear_contours(); + for contour in &change.layer.contours { + layer.add_contour(contour_from_value(contour)); + } + layer.clear_anchors(); + let mut anchors = change.layer.anchors.iter().collect::>(); + anchors.sort_by_key(|anchor| anchor.order_index); + for anchor in anchors { + layer.add_anchor(font::Anchor::with_id( + anchor.id.clone(), + anchor.name.clone(), + anchor.x, + anchor.y, + )); + } + Ok(()) + }) } } } +fn changed_layer_id(change: &font::FontChange) -> Option<&font::LayerId> { + match change { + font::FontChange::GlyphLayerCreated(change) => Some(&change.layer_id), + font::FontChange::GlyphLayerDeleted(change) => Some(&change.layer_id), + font::FontChange::LayerMetricsChanged(change) => Some(&change.layer_id), + font::FontChange::ContourAdded(change) => Some(&change.layer_id), + font::FontChange::ContourOpenClosedChanged(change) => Some(&change.layer_id), + font::FontChange::PointsAdded(change) => Some(&change.layer_id), + font::FontChange::PointsDeleted(change) => Some(&change.layer_id), + font::FontChange::PointSmoothChanged(change) => Some(&change.layer_id), + font::FontChange::PointPositionsChanged(change) => Some(&change.layer_id), + font::FontChange::AnchorPositionsChanged(change) => Some(&change.layer_id), + font::FontChange::LayerGeometryReplaced(change) => Some(&change.layer_id), + _ => None, + } +} + +fn update_packed_layer( + tx: &Transaction<'_>, + layer_id: &font::LayerId, + update: impl FnOnce(&mut font::GlyphLayer) -> Result<(), StoreError>, +) -> Result<(), StoreError> { + let mut layer = + crate::packed_layer::load_glyph_layer_from_conn(tx, layer_id)?.ok_or_else(|| { + StoreError::MissingEntity { + kind: "glyph layer", + id: layer_id.to_string(), + } + })?; + update(&mut layer)?; + rewrite_layer_in_tx(tx, &layer) +} + +fn contour_from_value(value: &font::ContourValue) -> font::Contour { + let mut contour = font::Contour::with_id(value.id.clone()); + let mut points = value.points.iter().collect::>(); + points.sort_by_key(|point| point.order_index); + for point in points { + contour.push_point(font::Point::new( + point.id.clone(), + point.x, + point.y, + point.point_type, + point.smooth, + )); + } + if value.closed { + contour.close(); + } + contour +} + fn upsert_axis_with_order( tx: &Transaction<'_>, axis: &font::Axis, @@ -611,107 +711,6 @@ fn replace_glyph_unicodes( Ok(()) } -fn upsert_layer( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - glyph_id: &font::GlyphId, - source_id: &font::SourceId, - name: Option<&font::GlyphName>, - width: f64, - height: Option, -) -> Result<(), StoreError> { - tx.execute( - " - INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - glyph_id = excluded.glyph_id, - source_id = excluded.source_id, - name = excluded.name - ", - params![ - layer_row_id(layer_id), - glyph_id.to_string(), - source_id.to_string(), - name.map(font::GlyphName::as_str), - width, - height, - ], - )?; - Ok(()) -} - -fn replace_layer_geometry( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - layer: &font::GlyphLayerValue, -) -> Result<(), StoreError> { - let rows_changed = tx.execute( - " - UPDATE glyph_layers - SET width = ?2, height = ?3 - WHERE id = ?1 - ", - params![layer_row_id(layer_id), layer.width, layer.height], - )?; - require_changed(rows_changed, "glyph layer", layer_row_id(layer_id))?; - tx.execute( - " - DELETE FROM glyph_layer_contours - WHERE layer_id = ?1 - ", - [layer_row_id(layer_id)], - )?; - - for (order_index, contour) in layer.contours.iter().enumerate() { - insert_contour(tx, layer_id, order_index, contour)?; - } - - tx.execute( - " - DELETE FROM glyph_layer_anchors - WHERE layer_id = ?1 - ", - [layer_row_id(layer_id)], - )?; - - for anchor in &layer.anchors { - insert_anchor(tx, layer_id, anchor)?; - } - - Ok(()) -} - -fn replace_full_layer_state( - tx: &Transaction<'_>, - layer: &font::GlyphLayer, -) -> Result<(), StoreError> { - replace_layer_geometry(tx, &layer.id(), &font::GlyphLayerValue::from(layer))?; - - tx.execute( - " - DELETE FROM glyph_components - WHERE layer_id = ?1 - ", - [layer_row_id(&layer.id())], - )?; - - for (order_index, component) in layer.components_iter().enumerate() { - insert_component(tx, &layer.id(), component, order_index)?; - } - - replace_layer_guidelines(tx, &layer.id(), layer.guidelines())?; - replace_lib_data( - tx, - "glyph_layer_lib", - "layer_id", - Some(&layer.id().to_string()), - layer.lib(), - )?; - - Ok(()) -} - /// Updates only authored metadata columns, preserving metrics and store-only fields. fn update_font_metadata( tx: &Transaction<'_>, @@ -846,135 +845,26 @@ fn replace_font_guidelines( ) -> Result<(), StoreError> { tx.execute("DELETE FROM font_guidelines", [])?; for (order_index, guideline) in guidelines.iter().enumerate() { - insert_guideline(tx, "font_guidelines", None, guideline, order_index)?; - } - Ok(()) -} - -fn replace_layer_guidelines( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - guidelines: &[font::Guideline], -) -> Result<(), StoreError> { - tx.execute( - " - DELETE FROM glyph_layer_guidelines - WHERE layer_id = ?1 - ", - [layer_row_id(layer_id)], - )?; - for (order_index, guideline) in guidelines.iter().enumerate() { - insert_guideline( - tx, - "glyph_layer_guidelines", - Some(&layer_row_id(layer_id)), - guideline, - order_index, + tx.execute( + " + INSERT INTO font_guidelines ( + id, x, y, angle, name, color, order_index + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ", + params![ + guideline.id().to_string(), + guideline.x(), + guideline.y(), + guideline.angle(), + guideline.name(), + guideline.color(), + order_index as i64, + ], )?; } Ok(()) } - -fn insert_guideline( - tx: &Transaction<'_>, - table: &'static str, - layer_id: Option<&str>, - guideline: &font::Guideline, - order_index: usize, -) -> Result<(), StoreError> { - match layer_id { - Some(layer_id) => { - debug_assert_eq!(table, "glyph_layer_guidelines"); - tx.execute( - " - INSERT INTO glyph_layer_guidelines ( - id, layer_id, x, y, angle, name, color, order_index - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ", - params![ - guideline.id().to_string(), - layer_id, - guideline.x(), - guideline.y(), - guideline.angle(), - guideline.name(), - guideline.color(), - order_index as i64, - ], - )?; - } - None => { - debug_assert_eq!(table, "font_guidelines"); - tx.execute( - " - INSERT INTO font_guidelines ( - id, x, y, angle, name, color, order_index - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ", - params![ - guideline.id().to_string(), - guideline.x(), - guideline.y(), - guideline.angle(), - guideline.name(), - guideline.color(), - order_index as i64, - ], - )?; - } - } - Ok(()) -} - -fn insert_component( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - component: &font::Component, - order_index: usize, -) -> Result<(), StoreError> { - let transform = component.transform(); - tx.execute( - " - INSERT INTO glyph_components ( - id, - layer_id, - base_glyph_id, - base_glyph_name, - translate_x, - translate_y, - rotation, - scale_x, - scale_y, - skew_x, - skew_y, - t_center_x, - t_center_y, - order_index - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) - ", - params![ - component.id().to_string(), - layer_row_id(layer_id), - component.base_glyph_id().to_string(), - component.base_glyph_name().as_str(), - transform.translate_x, - transform.translate_y, - transform.rotation, - transform.scale_x, - transform.scale_y, - transform.skew_x, - transform.skew_y, - transform.t_center_x, - transform.t_center_y, - order_index as i64, - ], - )?; - Ok(()) -} - fn replace_kerning(tx: &Transaction<'_>, kerning: &font::KerningData) -> Result<(), StoreError> { tx.execute("DELETE FROM kerning_pairs", [])?; tx.execute("DELETE FROM kerning_group_members", [])?; @@ -1134,134 +1024,6 @@ fn typed_json(kind: &'static str, value: serde_json::Value) -> serde_json::Value }) } -fn replace_contour( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - contour: &font::ContourValue, -) -> Result<(), StoreError> { - tx.execute( - " - INSERT INTO glyph_layer_contours (id, layer_id, closed, order_index) - VALUES (?1, ?2, ?3, COALESCE( - (SELECT order_index FROM glyph_layer_contours WHERE id = ?1), - (SELECT COUNT(*) FROM glyph_layer_contours WHERE layer_id = ?2) - )) - ON CONFLICT(id) DO UPDATE SET - closed = excluded.closed, - layer_id = excluded.layer_id - ", - params![ - contour.id.to_string(), - layer_row_id(layer_id), - contour.closed, - ], - )?; - tx.execute( - "DELETE FROM glyph_layer_points WHERE contour_id = ?1", - [contour.id.to_string()], - )?; - - for point in &contour.points { - insert_point(tx, &contour.id, point)?; - } - - Ok(()) -} - -fn insert_contour( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - order_index: usize, - contour: &font::ContourValue, -) -> Result<(), StoreError> { - tx.execute( - " - INSERT INTO glyph_layer_contours (id, layer_id, closed, order_index) - VALUES (?1, ?2, ?3, ?4) - ", - params![ - contour.id.to_string(), - layer_row_id(layer_id), - contour.closed, - order_index as i64, - ], - )?; - - for point in &contour.points { - insert_point(tx, &contour.id, point)?; - } - - Ok(()) -} - -fn insert_point( - tx: &Transaction<'_>, - contour_id: &font::ContourId, - point: &font::PointValue, -) -> Result<(), StoreError> { - tx.execute( - " - INSERT INTO glyph_layer_points ( - id, - contour_id, - order_index, - x, - y, - point_type, - smooth - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ", - params![ - point.id.to_string(), - contour_id.to_string(), - point.order_index as i64, - point.x, - point.y, - point_type_name(point.point_type), - point.smooth, - ], - )?; - Ok(()) -} - -fn insert_anchor( - tx: &Transaction<'_>, - layer_id: &font::LayerId, - anchor: &font::AnchorValue, -) -> Result<(), StoreError> { - tx.execute( - " - INSERT INTO glyph_layer_anchors ( - id, - layer_id, - name, - x, - y, - order_index - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ", - params![ - anchor.id.to_string(), - layer_row_id(layer_id), - anchor.name, - anchor.x, - anchor.y, - anchor.order_index as i64, - ], - )?; - Ok(()) -} - -fn point_type_name(point_type: font::PointType) -> &'static str { - match point_type { - font::PointType::OnCurve => "onCurve", - font::PointType::OffCurve => "offCurve", - font::PointType::QCurve => "qCurve", - } -} - fn require_changed(rows_changed: usize, kind: &'static str, id: String) -> Result<(), StoreError> { if rows_changed == 0 { Err(StoreError::MissingEntity { kind, id }) @@ -1270,22 +1032,6 @@ fn require_changed(rows_changed: usize, kind: &'static str, id: String) -> Resul } } -fn require_layer_exists(tx: &Transaction<'_>, layer_id: &font::LayerId) -> Result<(), StoreError> { - let exists: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM glyph_layers WHERE id = ?1)", - [layer_row_id(layer_id)], - |row| row.get(0), - )?; - if exists { - Ok(()) - } else { - Err(StoreError::MissingEntity { - kind: "glyph layer", - id: layer_row_id(layer_id), - }) - } -} - fn layer_row_id(layer_id: &font::LayerId) -> String { layer_id.to_string() } diff --git a/crates/shift-store/src/component.rs b/crates/shift-store/src/component.rs index 81351cfb..9111d1f3 100644 --- a/crates/shift-store/src/component.rs +++ b/crates/shift-store/src/component.rs @@ -1,3 +1,5 @@ +use rusqlite::OptionalExtension; + use crate::{ComponentId, GlyphId, LayerId, ShiftStore, StoreError}; pub struct NewGlyphComponent { @@ -24,130 +26,81 @@ impl ShiftStore { &mut self, component: NewGlyphComponent, ) -> Result<(), StoreError> { - self.conn.execute( - " - INSERT INTO glyph_components ( - id, - layer_id, - base_glyph_id, - base_glyph_name, - translate_x, - translate_y, - rotation, - scale_x, - scale_y, - skew_x, - skew_y, - t_center_x, - t_center_y, - order_index - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14) - ", - rusqlite::params![ - component.id.as_str(), - component.layer_id.as_str(), - component.base_glyph_id.as_str(), - component.base_glyph_name, - component.transform.translate_x, - component.transform.translate_y, - component.transform.rotation, - component.transform.scale_x, - component.transform.scale_y, - component.transform.skew_x, - component.transform.skew_y, - component.transform.t_center_x, - component.transform.t_center_y, - component.order_index, - ], - )?; - - Ok(()) + let layer_id = shift_font::LayerId::from_raw(component.layer_id.as_str()); + let mut layer = + self.load_glyph_layer(&layer_id)? + .ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer_id.to_string(), + })?; + if component.order_index != layer.components().len() as i64 { + return Err(StoreError::InvalidWorkspaceState(format!( + "component order {} does not append to layer {}", + component.order_index, layer_id + ))); + } + layer.add_component(shift_font::Component::with_id( + shift_font::ComponentId::from_raw(component.id.as_str()), + shift_font::GlyphId::from_raw(component.base_glyph_id.as_str()), + component.base_glyph_name, + component.transform, + )); + self.replace_glyph_layer(&layer) } pub fn get_glyph_component( &self, id: &ComponentId, ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT - id, - layer_id, - base_glyph_id, - base_glyph_name, - translate_x, - translate_y, - rotation, - scale_x, - scale_y, - skew_x, - skew_y, - t_center_x, - t_center_y, - order_index - FROM glyph_components - WHERE id = ?1 - ", - )?; - - match stmt.query_row([id.as_str()], map_glyph_component_row) { - Ok(component) => Ok(Some(component)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(err) => Err(err.into()), - } + let layer_id = self + .conn + .query_row( + "SELECT layer_id FROM glyph_components WHERE id = ?1", + [id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + let Some(layer_id) = layer_id else { + return Ok(None); + }; + let layer_id = shift_font::LayerId::from_raw(layer_id); + let Some(layer) = self.load_glyph_layer(&layer_id)? else { + return Ok(None); + }; + Ok(layer + .components_iter() + .enumerate() + .find(|(_, component)| component.id().as_str() == id.as_str()) + .map(|(order_index, component)| component_record(&layer, component, order_index))) } pub fn list_glyph_components_for_layer( &self, layer_id: &LayerId, ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT - id, - layer_id, - base_glyph_id, - base_glyph_name, - translate_x, - translate_y, - rotation, - scale_x, - scale_y, - skew_x, - skew_y, - t_center_x, - t_center_y, - order_index - FROM glyph_components - WHERE layer_id = ?1 - ORDER BY order_index, id - ", - )?; - - let rows = stmt.query_map([layer_id.as_str()], map_glyph_component_row)?; - rows.collect::, _>>() - .map_err(StoreError::from) + let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); + let Some(layer) = self.load_glyph_layer(&layer_id)? else { + return Ok(Vec::new()); + }; + Ok(layer + .components_iter() + .enumerate() + .map(|(order_index, component)| component_record(&layer, component, order_index)) + .collect()) } } -fn map_glyph_component_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(GlyphComponentRecord { - id: ComponentId::new(row.get::<_, String>(0)?), - layer_id: LayerId::new(row.get::<_, String>(1)?), - base_glyph_id: GlyphId::new(row.get::<_, String>(2)?), - base_glyph_name: row.get(3)?, - transform: shift_font::DecomposedTransform { - translate_x: row.get(4)?, - translate_y: row.get(5)?, - rotation: row.get(6)?, - scale_x: row.get(7)?, - scale_y: row.get(8)?, - skew_x: row.get(9)?, - skew_y: row.get(10)?, - t_center_x: row.get(11)?, - t_center_y: row.get(12)?, - }, - order_index: row.get(13)?, - }) +fn component_record( + layer: &shift_font::GlyphLayer, + component: &shift_font::Component, + order_index: usize, +) -> GlyphComponentRecord { + GlyphComponentRecord { + id: ComponentId::new(component.id().to_string()), + layer_id: LayerId::new(layer.id().to_string()), + base_glyph_id: GlyphId::new(component.base_glyph_id().to_string()), + base_glyph_name: component.base_glyph_name().to_string(), + transform: *component.transform(), + order_index: order_index as i64, + } } diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 535aa97c..4f9a37cf 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -9,6 +9,21 @@ pub enum StoreError { #[error(transparent)] Font(#[from] shift_font::error::CoreError), + #[error(transparent)] + PackedLayer(#[from] shift_font::PackedLayerError), + + #[error("unsupported glyph layer payload format: {0}")] + UnsupportedLayerFormat(String), + + #[error("glyph layer payload has {bytes} bytes; limit is {limit}")] + LayerPayloadTooLarge { bytes: u64, limit: u64 }, + + #[error("glyph layer {layer_id} payload disagrees with its directory: {detail}")] + LayerDirectoryMismatch { layer_id: String, detail: String }, + + #[error("glyph layer {layer_id} has a stale component/reference index")] + StaleLayerReferenceIndex { layer_id: String }, + #[error("unknown source kind: {0}")] UnknownSourceKind(String), diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index 736507f0..d64c826a 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; -use std::str::FromStr; use rusqlite::params; use shift_font as font; @@ -7,7 +6,19 @@ use shift_font as font; use crate::{FontInfo, ShiftStore, StoreError, source::SourceKind}; impl ShiftStore { + /// Materializes the complete font. Use only for export/save boundaries; + /// interactive open should use [`Self::load_font_directory`]. pub fn load_font_state(&self) -> Result { + self.load_font(true) + } + + /// Loads eager font metadata and the glyph/layer directory without + /// reading any layer payload BLOB. + pub fn load_font_directory(&self) -> Result { + self.load_font(false) + } + + fn load_font(&self, include_layer_payloads: bool) -> Result { let mut font = font::Font::empty(); if let Some(info) = self.get_font_info()? { @@ -43,7 +54,7 @@ impl ShiftStore { font.set_default_source_id(default_source_id); } - for glyph in load_glyphs(&self.conn)? { + for glyph in load_glyphs(&self.conn, include_layer_payloads)? { font.insert_glyph(glyph).map_err(StoreError::from)?; } @@ -297,257 +308,114 @@ fn load_source_location( Ok(location) } -fn load_glyphs(conn: &rusqlite::Connection) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, name - FROM glyphs - ORDER BY order_index, id - ", - )?; - - let rows = stmt.query_map([], |row| { - Ok(( - font::GlyphId::from_raw(row.get::<_, String>(0)?), - row.get::<_, Option>(1)?.unwrap_or_default(), - )) - })?; - - let mut glyphs = Vec::new(); - for row in rows { - let (glyph_id, name) = row?; - let mut glyph = font::Glyph::with_id(glyph_id.clone(), name); - glyph.set_unicodes(load_glyph_unicodes(conn, &glyph_id)?); - *glyph.lib_mut() = - load_lib_data(conn, "glyph_lib", Some(("glyph_id", &glyph_id.to_string())))?; - - for layer in load_layers_for_glyph(conn, &glyph_id)? { - glyph.set_layer(layer); - } - - glyphs.push(glyph); - } - - Ok(glyphs) -} - -fn load_glyph_unicodes( +fn load_glyphs( conn: &rusqlite::Connection, - glyph_id: &font::GlyphId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT unicode - FROM glyph_unicodes - WHERE glyph_id = ?1 - ORDER BY order_index - ", - )?; - let rows = stmt.query_map([glyph_id.to_string()], |row| row.get::<_, i64>(0))?; - rows.map(|row| row.map(|value| value as u32)) - .collect::, _>>() - .map_err(StoreError::from) -} - -fn load_layers_for_glyph( - conn: &rusqlite::Connection, - glyph_id: &font::GlyphId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, source_id, width, height - FROM glyph_layers - WHERE glyph_id = ?1 - ORDER BY id - ", - )?; - - let rows = stmt.query_map([glyph_id.to_string()], |row| { - Ok(( - font::LayerId::from_raw(row.get::<_, String>(0)?), - font::SourceId::from_raw(row.get::<_, String>(1)?), - row.get::<_, f64>(2)?, - row.get::<_, Option>(3)?, - )) - })?; - - let mut layers = Vec::new(); - for row in rows { - let (layer_id, source_id, width, height) = row?; - let mut layer = font::GlyphLayer::with_width(layer_id.clone(), source_id, width); - layer.set_height(height); + include_layer_payloads: bool, +) -> Result, StoreError> { + let glyph_rows = { + let mut stmt = conn.prepare("SELECT id, name FROM glyphs ORDER BY order_index, id")?; + stmt.query_map([], |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + row.get::<_, Option>(1)?.unwrap_or_default(), + )) + })? + .collect::, _>>()? + }; - for contour in load_contours_for_layer(conn, &layer_id)? { - layer.add_contour(contour); - } - for component in load_components_for_layer(conn, &layer_id)? { - layer.add_component(component); - } - for anchor in load_anchors_for_layer(conn, &layer_id)? { - layer.add_anchor(anchor); - } - for guideline in load_layer_guidelines(conn, &layer_id)? { - layer.add_guideline(guideline); - } - *layer.lib_mut() = load_lib_data( - conn, - "glyph_layer_lib", - Some(("layer_id", &layer_id.to_string())), + let mut unicodes: HashMap> = HashMap::new(); + { + let mut stmt = conn.prepare( + "SELECT glyph_id, unicode, order_index FROM glyph_unicodes ORDER BY glyph_id, order_index", )?; - - layers.push(layer); + let rows = stmt.query_map([], |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + row.get::<_, i64>(1)? as u32, + row.get::<_, i64>(2)?, + )) + })?; + for row in rows { + let (glyph_id, unicode, order_index) = row?; + unicodes + .entry(glyph_id) + .or_default() + .push((order_index, unicode)); + } } - Ok(layers) -} - -fn load_contours_for_layer( - conn: &rusqlite::Connection, - layer_id: &font::LayerId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, closed - FROM glyph_layer_contours - WHERE layer_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([layer_id.to_string()], |row| { - Ok(( - font::ContourId::from_raw(row.get::<_, String>(0)?), - row.get::<_, bool>(1)?, - )) - })?; - - let mut contours = Vec::new(); - for row in rows { - let (contour_id, closed) = row?; - let mut contour = font::Contour::with_id(contour_id.clone()); - for point in load_points_for_contour(conn, &contour_id)? { - contour.push_point(point); - } - if closed { - contour.close(); + let mut libs: HashMap> = HashMap::new(); + { + let mut stmt = conn.prepare("SELECT glyph_id, key, value_json FROM glyph_lib")?; + let rows = stmt.query_map([], |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + })?; + for row in rows { + let (glyph_id, key, value_json) = row?; + libs.entry(glyph_id) + .or_default() + .insert(key, lib_value_from_json(&value_json)?); } - contours.push(contour); } - Ok(contours) -} - -fn load_points_for_contour( - conn: &rusqlite::Connection, - contour_id: &font::ContourId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, x, y, point_type, smooth - FROM glyph_layer_points - WHERE contour_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([contour_id.to_string()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, f64>(1)?, - row.get::<_, f64>(2)?, - row.get::<_, String>(3)?, - row.get::<_, bool>(4)?, - )) - })?; - - let mut points = Vec::new(); - for row in rows { - let (id, x, y, point_type, smooth) = row?; - let point_type = - font::PointType::from_str(&point_type).map_err(StoreError::InvalidPointType)?; - points.push(font::Point::new( - font::PointId::from_raw(id), - x, - y, - point_type, - smooth, - )); + let layer_rows = { + let mut stmt = conn.prepare( + " + SELECT id, glyph_id, source_id, width, height + FROM glyph_layers + ORDER BY glyph_id, source_id, id + ", + )?; + stmt.query_map([], |row| { + Ok(( + font::LayerId::from_raw(row.get::<_, String>(0)?), + font::GlyphId::from_raw(row.get::<_, String>(1)?), + font::SourceId::from_raw(row.get::<_, String>(2)?), + row.get::<_, f64>(3)?, + row.get::<_, Option>(4)?, + )) + })? + .collect::, _>>()? + }; + let mut layers: HashMap> = HashMap::new(); + for (layer_id, glyph_id, source_id, width, height) in layer_rows { + let layer = if include_layer_payloads { + crate::packed_layer::load_glyph_layer_from_conn(conn, &layer_id)?.ok_or_else(|| { + StoreError::MissingEntity { + kind: "glyph layer payload", + id: layer_id.to_string(), + } + })? + } else { + let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); + layer.set_height(height); + layer + }; + layers.entry(glyph_id).or_default().push(layer); } - Ok(points) -} - -fn load_components_for_layer( - conn: &rusqlite::Connection, - layer_id: &font::LayerId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT - id, - base_glyph_id, - base_glyph_name, - translate_x, - translate_y, - rotation, - scale_x, - scale_y, - skew_x, - skew_y, - t_center_x, - t_center_y - FROM glyph_components - WHERE layer_id = ?1 - ORDER BY order_index, id - ", - )?; - let rows = stmt.query_map([layer_id.to_string()], |row| { - Ok(font::Component::with_id( - font::ComponentId::from_raw(row.get::<_, String>(0)?), - font::GlyphId::from_raw(row.get::<_, String>(1)?), - row.get::<_, String>(2)?, - font::DecomposedTransform { - translate_x: row.get(3)?, - translate_y: row.get(4)?, - rotation: row.get(5)?, - scale_x: row.get(6)?, - scale_y: row.get(7)?, - skew_x: row.get(8)?, - skew_y: row.get(9)?, - t_center_x: row.get(10)?, - t_center_y: row.get(11)?, - }, - )) - })?; - - rows.collect::, _>>() - .map_err(StoreError::from) -} - -fn load_anchors_for_layer( - conn: &rusqlite::Connection, - layer_id: &font::LayerId, -) -> Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, name, x, y - FROM glyph_layer_anchors - WHERE layer_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([layer_id.to_string()], |row| { - Ok(font::Anchor::with_id( - font::AnchorId::from_raw(row.get::<_, String>(0)?), - row.get::<_, Option>(1)?, - row.get(2)?, - row.get(3)?, - )) - })?; - - rows.collect::, _>>() - .map_err(StoreError::from) + let mut glyphs = Vec::with_capacity(glyph_rows.len()); + for (glyph_id, name) in glyph_rows { + let mut glyph = font::Glyph::with_id(glyph_id.clone(), name); + let mut glyph_unicodes = unicodes.remove(&glyph_id).unwrap_or_default(); + glyph_unicodes.sort_by_key(|(order_index, _)| *order_index); + glyph.set_unicodes( + glyph_unicodes + .into_iter() + .map(|(_, unicode)| unicode) + .collect(), + ); + *glyph.lib_mut() = font::LibData::from_map(libs.remove(&glyph_id).unwrap_or_default()); + for layer in layers.remove(&glyph_id).unwrap_or_default() { + glyph.set_layer(layer); + } + glyphs.push(glyph); + } + Ok(glyphs) } fn load_font_guidelines(conn: &rusqlite::Connection) -> Result, StoreError> { @@ -564,24 +432,6 @@ fn load_font_guidelines(conn: &rusqlite::Connection) -> Result Result, StoreError> { - let mut stmt = conn.prepare( - " - SELECT id, x, y, angle, name, color - FROM glyph_layer_guidelines - WHERE layer_id = ?1 - ORDER BY order_index - ", - )?; - let rows = stmt.query_map([layer_id.to_string()], map_guideline_row)?; - - rows.collect::, _>>() - .map_err(StoreError::from) -} - fn map_guideline_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(font::Guideline::with_id( font::GuidelineId::from_raw(row.get::<_, String>(0)?), diff --git a/crates/shift-store/src/layer.rs b/crates/shift-store/src/layer.rs index 6c3b983a..33b76882 100644 --- a/crates/shift-store/src/layer.rs +++ b/crates/shift-store/src/layer.rs @@ -19,24 +19,21 @@ pub struct GlyphLayerRecord { impl ShiftStore { pub fn create_glyph_layer(&mut self, layer: NewGlyphLayer) -> Result<(), StoreError> { - self.conn.execute( - " - INSERT INTO glyph_layers ( - id, - glyph_id, - source_id, - name - ) - VALUES (?1, ?2, ?3, ?4) - ", - rusqlite::params![ - layer.id.as_str(), - layer.glyph_id.as_str(), - layer.source_id.as_str(), - layer.name, - ], + let tx = self.conn.transaction()?; + crate::packed_layer::create_empty_layer_in_tx( + &tx, + &shift_font::GlyphId::from_raw(layer.glyph_id.as_str()), + layer + .name + .as_deref() + .map(shift_font::GlyphName::from) + .as_ref(), + shift_font::LayerId::from_raw(layer.id.as_str()), + shift_font::SourceId::from_raw(layer.source_id.as_str()), + 0.0, + None, )?; - + tx.commit()?; Ok(()) } diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 967c075e..44c1d138 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -7,9 +7,11 @@ mod font_state; mod glyph; mod layer; mod outline; +mod packed_layer; mod schema; mod source; mod store; +mod stream_writer; mod types; mod workspace_state; @@ -19,8 +21,10 @@ pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; pub use layer::{GlyphLayerRecord, NewGlyphLayer}; pub use outline::{AnchorRecord, ContourRecord, PointRecord}; +pub use packed_layer::{GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; +pub use stream_writer::LayerStreamWriter; pub use types::{AxisId, ComponentId, GlyphId, LayerId, RevisionId, SourceId}; pub use workspace_state::{ Evidence, FileIdentity, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState, diff --git a/crates/shift-store/src/outline.rs b/crates/shift-store/src/outline.rs index f2a59267..d3000873 100644 --- a/crates/shift-store/src/outline.rs +++ b/crates/shift-store/src/outline.rs @@ -34,85 +34,83 @@ impl ShiftStore { &self, layer_id: &LayerId, ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT id, layer_id, closed, order_index - FROM glyph_layer_contours - WHERE layer_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([layer_id.as_str()], map_contour_row)?; - rows.collect::, _>>() - .map_err(StoreError::from) + let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); + let Some(layer) = self.load_glyph_layer(&layer_id)? else { + return Ok(Vec::new()); + }; + Ok(layer + .contours_iter() + .enumerate() + .map(|(order_index, contour)| ContourRecord { + id: contour.id().to_string(), + layer_id: LayerId::new(layer.id().to_string()), + closed: contour.is_closed(), + order_index: order_index as i64, + }) + .collect()) } + /// Compatibility lookup for callers that only retain a contour id. + /// Interactive acquisition should address a layer directly. pub fn list_points_for_contour( &self, contour_id: &str, ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT id, contour_id, order_index, x, y, point_type, smooth - FROM glyph_layer_points - WHERE contour_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([contour_id], map_point_row)?; - rows.collect::, _>>() - .map_err(StoreError::from) + for entry in self.list_glyph_layer_directory()? { + let Some(layer) = self.load_glyph_layer(&entry.layer_id)? else { + continue; + }; + let Some(contour) = layer + .contours_iter() + .find(|contour| contour.id().as_str() == contour_id) + else { + continue; + }; + return Ok(contour + .points() + .iter() + .enumerate() + .map(|(order_index, point)| PointRecord { + id: point.id().to_string(), + contour_id: contour_id.to_owned(), + order_index: order_index as i64, + x: point.x(), + y: point.y(), + point_type: point_type_name(point.point_type()).to_owned(), + smooth: point.is_smooth(), + }) + .collect()); + } + Ok(Vec::new()) } pub fn list_anchors_for_layer( &self, layer_id: &LayerId, ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT id, layer_id, name, x, y, order_index - FROM glyph_layer_anchors - WHERE layer_id = ?1 - ORDER BY order_index - ", - )?; - - let rows = stmt.query_map([layer_id.as_str()], map_anchor_row)?; - rows.collect::, _>>() - .map_err(StoreError::from) + let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); + let Some(layer) = self.load_glyph_layer(&layer_id)? else { + return Ok(Vec::new()); + }; + Ok(layer + .anchors_iter() + .enumerate() + .map(|(order_index, anchor)| AnchorRecord { + id: anchor.id().to_string(), + layer_id: LayerId::new(layer.id().to_string()), + name: anchor.name().map(str::to_owned), + x: anchor.x(), + y: anchor.y(), + order_index: order_index as i64, + }) + .collect()) } } -fn map_contour_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(ContourRecord { - id: row.get(0)?, - layer_id: LayerId::new(row.get::<_, String>(1)?), - closed: row.get(2)?, - order_index: row.get(3)?, - }) -} - -fn map_point_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(PointRecord { - id: row.get(0)?, - contour_id: row.get(1)?, - order_index: row.get(2)?, - x: row.get(3)?, - y: row.get(4)?, - point_type: row.get(5)?, - smooth: row.get(6)?, - }) -} - -fn map_anchor_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(AnchorRecord { - id: row.get(0)?, - layer_id: LayerId::new(row.get::<_, String>(1)?), - name: row.get(2)?, - x: row.get(3)?, - y: row.get(4)?, - order_index: row.get(5)?, - }) +fn point_type_name(point_type: shift_font::PointType) -> &'static str { + match point_type { + shift_font::PointType::OnCurve => "onCurve", + shift_font::PointType::OffCurve => "offCurve", + shift_font::PointType::QCurve => "qCurve", + } } diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs new file mode 100644 index 00000000..b3e16549 --- /dev/null +++ b/crates/shift-store/src/packed_layer.rs @@ -0,0 +1,725 @@ +use std::collections::{HashSet, VecDeque}; + +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use shift_font as font; + +use crate::{ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx}; + +pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; +const MAX_LAYER_PAYLOAD_BYTES: i64 = shift_glyph_codec::MAX_LAYER_PAYLOAD_BYTES as i64; + +/// Relational facts needed to locate a layer without reading its payload. +#[derive(Clone, Debug, PartialEq)] +pub struct GlyphLayerDirectoryEntry { + pub layer_id: font::LayerId, + pub glyph_id: font::GlyphId, + pub source_id: font::SourceId, + pub name: Option, + pub width: f64, + pub height: Option, + pub payload_byte_length: u64, +} + +impl ShiftStore { + /// Fetches, bounds-checks, decodes, and cross-checks one canonical layer. + pub fn load_glyph_layer( + &self, + layer_id: &font::LayerId, + ) -> Result, StoreError> { + load_glyph_layer_from_conn(&self.conn, layer_id) + } + + /// Replaces one independently-addressed layer and its earned reference + /// rows in one transaction. The workspace revision advances only after + /// encoding and index derivation have succeeded. + pub fn replace_glyph_layer(&mut self, layer: &font::GlyphLayer) -> Result<(), StoreError> { + let owner = + layer_owner(&self.conn, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer.id().to_string(), + })?; + let tx = self.conn.transaction()?; + write_layer_in_tx(&tx, &owner.0, owner.1.as_ref(), layer)?; + mark_workspace_dirty_in_tx(&tx)?; + tx.commit()?; + Ok(()) + } + + /// Validates caller-provided canonical bytes before atomically replacing + /// directory facts, payload, reference rows, and revision state. + pub fn replace_glyph_layer_payload( + &mut self, + expected_layer_id: &font::LayerId, + payload: &[u8], + ) -> Result<(), StoreError> { + if payload.len() as i64 > MAX_LAYER_PAYLOAD_BYTES { + return Err(StoreError::LayerPayloadTooLarge { + bytes: payload.len() as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + let layer = font::unpack_glyph_layer(payload)?; + if layer.id() != *expected_layer_id { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: expected_layer_id.to_string(), + detail: format!("payload id is {}", layer.id()), + }); + } + self.replace_glyph_layer(&layer) + } + + pub fn list_glyph_layer_directory(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT l.id, l.glyph_id, l.source_id, l.name, l.width, l.height, + p.byte_length + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + ORDER BY l.glyph_id, l.source_id, l.id + ", + )?; + let rows = stmt.query_map([], map_directory_row)?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Returns referenced glyph identities entirely from the relational index. + pub fn referenced_glyph_ids_for_glyph( + &self, + glyph_id: &font::GlyphId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT c.base_glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS l ON l.id = c.layer_id + WHERE l.glyph_id = ?1 + ORDER BY c.base_glyph_id + ", + )?; + let rows = stmt.query_map([glyph_id.to_string()], |row| { + Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Computes the transitive component closure without scanning payloads. + pub fn referenced_glyph_closure( + &self, + roots: impl IntoIterator, + ) -> Result, StoreError> { + let mut pending: VecDeque<_> = roots.into_iter().collect(); + let mut seen = HashSet::new(); + let mut result = Vec::new(); + while let Some(glyph_id) = pending.pop_front() { + if !seen.insert(glyph_id.clone()) { + continue; + } + pending.extend(self.referenced_glyph_ids_for_glyph(&glyph_id)?); + result.push(glyph_id); + } + Ok(result) + } + + /// Reverse-reference lookup used for dependent invalidation. + pub fn dependent_glyph_ids_for_layers( + &self, + layer_ids: &[font::LayerId], + ) -> Result, StoreError> { + if layer_ids.is_empty() { + return Ok(Vec::new()); + } + let mut result = HashSet::new(); + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT owner.glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS target ON target.glyph_id = c.base_glyph_id + JOIN glyph_layers AS owner ON owner.id = c.layer_id + WHERE target.id = ?1 + ", + )?; + for layer_id in layer_ids { + let rows = stmt.query_map([layer_id.to_string()], |row| { + Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) + })?; + for row in rows { + result.insert(row?); + } + } + let mut result: Vec<_> = result.into_iter().collect(); + result.sort_by(|left, right| left.as_str().cmp(right.as_str())); + Ok(result) + } +} + +pub(crate) fn load_glyph_layer_from_conn( + conn: &Connection, + layer_id: &font::LayerId, +) -> Result, StoreError> { + let row = conn + .query_row( + " + SELECT l.glyph_id, l.source_id, l.width, l.height, + p.format, p.byte_length, length(p.payload) + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id = ?1 + ", + [layer_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }, + ) + .optional()?; + let Some((_glyph_id, source_id, width, height, format, declared_len, actual_len)) = row else { + return Ok(None); + }; + + if format != GLYPH_LAYER_FORMAT { + return Err(StoreError::UnsupportedLayerFormat(format)); + } + if declared_len != actual_len { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_string(), + detail: format!("declared byte length {declared_len} != actual {actual_len}"), + }); + } + if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&actual_len) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(actual_len).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + + // The length gate is deliberately a separate query so SQLite cannot + // allocate an unbounded Vec before the store applies its limit. + let payload: Vec = conn + .query_row( + " + SELECT payload + FROM glyph_layer_payloads + WHERE layer_id = ?1 + AND length(payload) = ?2 + AND length(payload) <= ?3 + ", + params![layer_id.to_string(), actual_len, MAX_LAYER_PAYLOAD_BYTES], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_string(), + detail: "payload changed during bounded read".to_string(), + })?; + let layer = font::unpack_glyph_layer(&payload)?; + validate_directory_facts(layer_id, &source_id, width, height, &layer)?; + validate_component_index(conn, &layer)?; + Ok(Some(layer)) +} + +pub(crate) fn write_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + name: Option<&font::GlyphName>, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let packed = font::pack_glyph_layer(layer)?; + let bytes = packed.as_bytes(); + + tx.execute( + " + INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET + glyph_id = excluded.glyph_id, + source_id = excluded.source_id, + name = excluded.name, + width = excluded.width, + height = excluded.height + ", + params![ + layer.id().to_string(), + glyph_id.to_string(), + layer.source_id().to_string(), + name.map(font::GlyphName::as_str), + layer.width(), + layer.height(), + ], + )?; + tx.execute( + " + INSERT INTO glyph_layer_payloads ( + layer_id, format, payload, byte_length + ) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(layer_id) DO UPDATE SET + format = excluded.format, + payload = excluded.payload, + byte_length = excluded.byte_length + ", + params![ + layer.id().to_string(), + GLYPH_LAYER_FORMAT, + bytes, + i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes: bytes.len() as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + })?, + ], + )?; + replace_component_index(tx, layer)?; + Ok(()) +} + +pub(crate) fn rewrite_layer_in_tx( + tx: &Transaction<'_>, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let owner = layer_owner(tx, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer.id().to_string(), + })?; + write_layer_in_tx(tx, &owner.0, owner.1.as_ref(), layer) +} + +pub(crate) fn create_empty_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + name: Option<&font::GlyphName>, + layer_id: font::LayerId, + source_id: font::SourceId, + width: f64, + height: Option, +) -> Result<(), StoreError> { + let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); + layer.set_height(height); + write_layer_in_tx(tx, glyph_id, name, &layer) +} + +fn replace_component_index( + tx: &Transaction<'_>, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + tx.execute( + "DELETE FROM glyph_components WHERE layer_id = ?1", + [layer.id().to_string()], + )?; + for (order_index, component) in layer.components_iter().enumerate() { + tx.execute( + " + INSERT INTO glyph_components ( + id, layer_id, base_glyph_id, order_index + ) VALUES (?1, ?2, ?3, ?4) + ", + params![ + component.id().to_string(), + layer.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ], + )?; + } + Ok(()) +} + +fn validate_directory_facts( + expected_id: &font::LayerId, + expected_source_id: &str, + expected_width: f64, + expected_height: Option, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let detail = if layer.id() != *expected_id { + Some(format!("payload id is {}", layer.id())) + } else if layer.source_id().as_str() != expected_source_id { + Some(format!("payload source id is {}", layer.source_id())) + } else if layer.width().to_bits() != expected_width.to_bits() { + Some("payload width differs from directory width".to_string()) + } else if !same_optional_f64(layer.height(), expected_height) { + Some("payload height differs from directory height".to_string()) + } else { + None + }; + if let Some(detail) = detail { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: expected_id.to_string(), + detail, + }); + } + Ok(()) +} + +fn validate_component_index(conn: &Connection, layer: &font::GlyphLayer) -> Result<(), StoreError> { + let mut stmt = conn.prepare( + " + SELECT id, base_glyph_id, order_index + FROM glyph_components + WHERE layer_id = ?1 + ORDER BY order_index, id + ", + )?; + let indexed = stmt + .query_map([layer.id().to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + })? + .collect::, _>>()?; + let authored: Vec<_> = layer + .components_iter() + .enumerate() + .map(|(order_index, component)| { + ( + component.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ) + }) + .collect(); + let matches = indexed == authored; + if !matches { + return Err(StoreError::StaleLayerReferenceIndex { + layer_id: layer.id().to_string(), + }); + } + Ok(()) +} + +fn layer_owner( + conn: &Connection, + layer_id: &font::LayerId, +) -> Result)>, StoreError> { + conn.query_row( + "SELECT glyph_id, name FROM glyph_layers WHERE id = ?1", + [layer_id.to_string()], + |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + row.get::<_, Option>(1)?.map(font::GlyphName::from), + )) + }, + ) + .optional() + .map_err(Into::into) +} + +fn map_directory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let byte_length = row.get::<_, i64>(6)?; + let payload_byte_length = u64::try_from(byte_length).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + 6, + rusqlite::types::Type::Integer, + Box::new(error), + ) + })?; + Ok(GlyphLayerDirectoryEntry { + layer_id: font::LayerId::from_raw(row.get::<_, String>(0)?), + glyph_id: font::GlyphId::from_raw(row.get::<_, String>(1)?), + source_id: font::SourceId::from_raw(row.get::<_, String>(2)?), + name: row.get::<_, Option>(3)?.map(font::GlyphName::from), + width: row.get(4)?, + height: row.get(5)?, + payload_byte_length, + }) +} + +fn same_optional_f64(left: Option, right: Option) -> bool { + match (left, right) { + (Some(left), Some(right)) => left.to_bits() == right.to_bits(), + (None, None) => true, + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use shift_font as font; + + use super::*; + use crate::{ShiftStore, WorkspaceState}; + + fn populated_store() -> (ShiftStore, font::Font) { + let mut store = ShiftStore::open_memory_for_test().unwrap(); + let font = font::test_support::sample_font(); + store.replace_font_state(&font).unwrap(); + store + .set_workspace_state(WorkspaceState::untitled(None)) + .unwrap(); + (store, font) + } + + #[test] + fn directory_open_never_reads_malformed_payloads() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads SET payload = X'00', byte_length = 1 WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + + let directory = store.load_font_directory().unwrap(); + assert_eq!(directory.glyph_count(), font.glyph_count()); + assert!(directory.layer(layer_id.clone()).unwrap().is_empty()); + assert!(store.load_glyph_layer(layer_id).is_err()); + } + + #[test] + fn stale_reference_index_is_rejected() { + let (store, font) = populated_store(); + let layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap(); + store + .conn + .execute( + "DELETE FROM glyph_components WHERE layer_id = ?1", + [layer.id().to_string()], + ) + .unwrap(); + + assert!(matches!( + store.load_glyph_layer(&layer.id()), + Err(StoreError::StaleLayerReferenceIndex { .. }) + )); + } + + #[test] + fn index_constraint_failure_rolls_back_payload_directory_and_revision() { + let (mut store, font) = populated_store(); + let indexed_layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap(); + let duplicate = indexed_layer.components_iter().next().unwrap(); + let mut target = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| layer.id() != indexed_layer.id() && layer.components().is_empty()) + .unwrap() + .as_ref() + .clone(); + target.add_component(font::Component::with_id( + duplicate.id(), + duplicate.base_glyph_id(), + duplicate.base_glyph_name().clone(), + *duplicate.transform(), + )); + + let before_payload: Vec = store + .conn + .query_row( + "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + [target.id().to_string()], + |row| row.get(0), + ) + .unwrap(); + let before_revision = store.workspace_state().unwrap().unwrap().revision; + + assert!(store.replace_glyph_layer(&target).is_err()); + + let after_payload: Vec = store + .conn + .query_row( + "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + [target.id().to_string()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(after_payload, before_payload); + assert_eq!( + store.workspace_state().unwrap().unwrap().revision, + before_revision + ); + } + + #[test] + fn replacement_updates_payload_directory_references_and_revision_atomically() { + let (mut store, font) = populated_store(); + let mut layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap() + .as_ref() + .clone(); + let owner = font.glyph_id_by_layer(layer.id()).unwrap(); + layer.set_width(layer.width() + 23.0); + layer.clear_components(); + + store.replace_glyph_layer(&layer).unwrap(); + + assert_eq!( + store.load_glyph_layer(&layer.id()).unwrap(), + Some(layer.clone()) + ); + let directory = store + .list_glyph_layer_directory() + .unwrap() + .into_iter() + .find(|entry| entry.layer_id == layer.id()) + .unwrap(); + assert_eq!(directory.width.to_bits(), layer.width().to_bits()); + assert!( + store + .referenced_glyph_ids_for_glyph(&owner) + .unwrap() + .is_empty() + ); + assert_eq!(store.workspace_state().unwrap().unwrap().revision, 1); + } + + #[test] + fn abandoned_transaction_preserves_last_committed_layer() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("working.sqlite"); + let font = font::test_support::sample_font(); + let layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .next() + .unwrap() + .as_ref() + .clone(); + let mut store = ShiftStore::open(&path).unwrap(); + store.replace_font_state(&font).unwrap(); + let mut uncommitted = layer.clone(); + uncommitted.set_width(layer.width() + 999.0); + { + let tx = store.conn.transaction().unwrap(); + rewrite_layer_in_tx(&tx, &uncommitted).unwrap(); + // Dropping an open transaction models recovery from a process + // that exits before COMMIT reaches the WAL. + } + drop(store); + + let reopened = ShiftStore::open(&path).unwrap(); + assert_eq!(reopened.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); + } + + #[test] + fn malformed_replacement_commits_nothing() { + let (mut store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + let before = store.list_glyph_layer_directory().unwrap(); + let before_revision = store.workspace_state().unwrap().unwrap().revision; + + assert!( + store + .replace_glyph_layer_payload(layer_id, b"not a layer") + .is_err() + ); + + assert_eq!(store.list_glyph_layer_directory().unwrap(), before); + assert_eq!( + store.workspace_state().unwrap().unwrap().revision, + before_revision + ); + } + + #[test] + fn stream_writer_commits_one_standalone_layer() { + let (mut store, font) = populated_store(); + let glyph = font.glyphs().next().unwrap(); + let mut layer = glyph.layers().values().next().unwrap().as_ref().clone(); + layer.set_width(layer.width() + 17.0); + let glyph_id = glyph.id(); + let glyph_name = glyph.glyph_name().clone(); + drop(font); + + store + .layer_stream_writer() + .write_layer(&glyph_id, Some(&glyph_name), &layer) + .unwrap(); + + assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); + } + + #[test] + fn cjk_scale_directory_open_is_payload_independent() { + const GLYPH_COUNT: usize = 65_536; + let mut store = ShiftStore::open_memory_for_test().unwrap(); + let source_id = font::SourceId::from_raw("cjk").to_string(); + store + .conn + .execute( + "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'CJK', 'master', 0)", + [&source_id], + ) + .unwrap(); + let tx = store.conn.transaction().unwrap(); + { + let mut glyph_stmt = tx + .prepare("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)") + .unwrap(); + let mut layer_stmt = tx + .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, 1000, NULL)") + .unwrap(); + let mut payload_stmt = tx + .prepare("INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, X'00', 1)") + .unwrap(); + for index in 0..GLYPH_COUNT { + let glyph_id = font::GlyphId::from_raw(format!("cjk{index}")).to_string(); + let layer_id = font::LayerId::from_raw(format!("cjk{index}")).to_string(); + let name = format!("uni{index:05X}"); + glyph_stmt + .execute(params![glyph_id, name, index as i64]) + .unwrap(); + layer_stmt + .execute(params![layer_id, glyph_id, source_id, name]) + .unwrap(); + payload_stmt + .execute(params![layer_id, GLYPH_LAYER_FORMAT]) + .unwrap(); + } + } + tx.commit().unwrap(); + + let started = Instant::now(); + let directory = store.load_font_directory().unwrap(); + let elapsed = started.elapsed(); + + assert_eq!(directory.glyph_count(), GLYPH_COUNT); + assert_eq!( + directory + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::(), + GLYPH_COUNT + ); + assert!( + elapsed < Duration::from_secs(15), + "directory open took {elapsed:?}" + ); + } +} diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index d9a7d50d..538e308f 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -112,51 +112,18 @@ ON glyph_layers(glyph_id); CREATE INDEX IF NOT EXISTS glyph_layers_source_id_idx ON glyph_layers(source_id); -CREATE TABLE IF NOT EXISTS glyph_layer_contours ( - id TEXT PRIMARY KEY, - layer_id TEXT NOT NULL, - closed INTEGER NOT NULL DEFAULT 0 CHECK (closed IN (0, 1)), - order_index INTEGER NOT NULL, +CREATE TABLE IF NOT EXISTS glyph_layer_payloads ( + layer_id TEXT PRIMARY KEY, + format TEXT NOT NULL, + payload BLOB NOT NULL, + byte_length INTEGER NOT NULL CHECK (byte_length >= 0 AND byte_length = length(payload)), FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE ); -CREATE UNIQUE INDEX IF NOT EXISTS glyph_layer_contours_layer_order_unique -ON glyph_layer_contours(layer_id, order_index); - -CREATE INDEX IF NOT EXISTS glyph_layer_contours_layer_id_idx -ON glyph_layer_contours(layer_id); - -CREATE TABLE IF NOT EXISTS glyph_layer_points ( - id TEXT PRIMARY KEY, - contour_id TEXT NOT NULL, - order_index INTEGER NOT NULL, - x REAL NOT NULL, - y REAL NOT NULL, - point_type TEXT NOT NULL, - smooth INTEGER NOT NULL DEFAULT 0 CHECK (smooth IN (0, 1)), - FOREIGN KEY (contour_id) REFERENCES glyph_layer_contours(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS glyph_layer_points_contour_order_unique -ON glyph_layer_points(contour_id, order_index); - -CREATE INDEX IF NOT EXISTS glyph_layer_points_contour_id_idx -ON glyph_layer_points(contour_id); - CREATE TABLE IF NOT EXISTS glyph_components ( id TEXT PRIMARY KEY, layer_id TEXT NOT NULL, base_glyph_id TEXT NOT NULL, - base_glyph_name TEXT NOT NULL, - translate_x REAL NOT NULL DEFAULT 0, - translate_y REAL NOT NULL DEFAULT 0, - rotation REAL NOT NULL DEFAULT 0, - scale_x REAL NOT NULL DEFAULT 1, - scale_y REAL NOT NULL DEFAULT 1, - skew_x REAL NOT NULL DEFAULT 0, - skew_y REAL NOT NULL DEFAULT 0, - t_center_x REAL NOT NULL DEFAULT 0, - t_center_y REAL NOT NULL DEFAULT 0, order_index INTEGER NOT NULL, FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE ); @@ -170,22 +137,6 @@ ON glyph_components(layer_id); CREATE INDEX IF NOT EXISTS glyph_components_base_glyph_id_idx ON glyph_components(base_glyph_id); -CREATE TABLE IF NOT EXISTS glyph_layer_anchors ( - id TEXT PRIMARY KEY, - layer_id TEXT NOT NULL, - name TEXT, - x REAL NOT NULL, - y REAL NOT NULL, - order_index INTEGER NOT NULL, - FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE -); - -CREATE UNIQUE INDEX IF NOT EXISTS glyph_layer_anchors_layer_order_unique -ON glyph_layer_anchors(layer_id, order_index); - -CREATE INDEX IF NOT EXISTS glyph_layer_anchors_layer_id_idx -ON glyph_layer_anchors(layer_id); - CREATE TABLE IF NOT EXISTS font_guidelines ( id TEXT PRIMARY KEY, x REAL, @@ -196,21 +147,6 @@ CREATE TABLE IF NOT EXISTS font_guidelines ( order_index INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS glyph_layer_guidelines ( - id TEXT PRIMARY KEY, - layer_id TEXT NOT NULL, - x REAL, - y REAL, - angle REAL, - name TEXT, - color TEXT, - order_index INTEGER NOT NULL, - FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS glyph_layer_guidelines_layer_id_idx -ON glyph_layer_guidelines(layer_id); - CREATE TABLE IF NOT EXISTS source_locations ( source_id TEXT NOT NULL, axis_id TEXT NOT NULL, @@ -285,14 +221,6 @@ CREATE TABLE IF NOT EXISTS glyph_lib ( FOREIGN KEY (glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE ); -CREATE TABLE IF NOT EXISTS glyph_layer_lib ( - layer_id TEXT NOT NULL, - key TEXT NOT NULL, - value_json TEXT NOT NULL, - PRIMARY KEY (layer_id, key), - FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE -); - CREATE TABLE IF NOT EXISTS font_binaries ( kind TEXT NOT NULL CHECK (kind IN ('data', 'image')), path TEXT NOT NULL, diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs new file mode 100644 index 00000000..42af0ba5 --- /dev/null +++ b/crates/shift-store/src/stream_writer.rs @@ -0,0 +1,34 @@ +use shift_font as font; + +use crate::{ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx}; + +/// Bounded sink for format-specific importers. +/// +/// The caller owns parsing and directory creation. Each call encodes one +/// authored layer, derives its relational reference rows, commits both, and +/// returns before the caller parses the next layer. No complete `Font` is +/// required by this API. +pub struct LayerStreamWriter<'store> { + store: &'store mut ShiftStore, +} + +impl ShiftStore { + pub fn layer_stream_writer(&mut self) -> LayerStreamWriter<'_> { + LayerStreamWriter { store: self } + } +} + +impl LayerStreamWriter<'_> { + pub fn write_layer( + &mut self, + glyph_id: &font::GlyphId, + glyph_name: Option<&font::GlyphName>, + layer: &font::GlyphLayer, + ) -> Result<(), StoreError> { + let tx = self.store.conn.transaction()?; + crate::packed_layer::write_layer_in_tx(&tx, glyph_id, glyph_name, layer)?; + mark_workspace_dirty_in_tx(&tx)?; + tx.commit()?; + Ok(()) + } +} diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 2c67983f..17aafb6e 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -700,7 +700,7 @@ fn rejects_layer_edit_for_missing_layer_row() { } fn create_glyph_a(store: &mut ShiftStore) -> GlyphId { - let glyph_id = GlyphId::new("glyph-A"); + let glyph_id = GlyphId::new(shift_font::GlyphId::from_raw("A").to_string()); store .create_glyph(NewGlyph { @@ -769,7 +769,7 @@ fn empty_font_info() -> FontInfo { } fn create_glyph_b(store: &mut ShiftStore) -> GlyphId { - let glyph_id = GlyphId::new("glyph-B"); + let glyph_id = GlyphId::new(shift_font::GlyphId::from_raw("B").to_string()); store .create_glyph(NewGlyph { @@ -786,7 +786,7 @@ fn create_default_glyph_layer( glyph_id: &GlyphId, source_id: &SourceId, ) -> LayerId { - let layer_id = LayerId::new("layer-A-regular"); + let layer_id = LayerId::new(shift_font::LayerId::from_raw("A-regular").to_string()); store .create_glyph_layer(NewGlyphLayer { @@ -805,7 +805,7 @@ fn create_default_component( layer_id: &LayerId, base_glyph_id: &GlyphId, ) -> ComponentId { - let component_id = ComponentId::new("component-A-B"); + let component_id = ComponentId::new(shift_font::ComponentId::from_raw("A-B").to_string()); store .create_glyph_component(NewGlyphComponent { @@ -840,7 +840,7 @@ fn create_weight_axis(store: &mut ShiftStore) -> AxisId { } fn create_regular_source(store: &mut ShiftStore) -> SourceId { - let source_id = SourceId::new("source-regular"); + let source_id = SourceId::new(shift_font::SourceId::from_raw("regular").to_string()); store .create_source(NewSource { diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 50dd3120..d50a6918 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -4,7 +4,9 @@ Backend runtime object for an open Shift font workspace. ## Architecture Invariants -- **Architecture Invariant:** `FontWorkspace` composes the live `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. +- **Architecture Invariant:** `FontWorkspace` composes a directory-complete, payload-lazy `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. +- **Architecture Invariant:** Resuming SQLite loads metadata and glyph/layer directory facts only. `acquire_glyphs` performs explicit bounded payload I/O; synchronous `font()` reads never initiate I/O. +- **Architecture Invariant:** A loaded layer is only a cache of already-committed authored state. `evict_glyphs` replaces it with a directory placeholder and cannot lose an edit. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. - **Architecture Invariant:** The workspace is the domain object future bridge or utility-process transports should wrap. @@ -43,6 +45,8 @@ crates/shift-workspace/src/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, include_references)` fetches requested layers and can expand component closure from the relational reference index. Acquisition decodes one layer at a time into a COW candidate font, so peak temporary memory is bounded and a malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. + ## Verification - `cargo test -p shift-workspace` diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 47fe275b..44deb491 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashSet, io, path::{Path, PathBuf}, }; @@ -70,6 +71,7 @@ pub struct FontWorkspace { source: WorkspaceSource, store: ShiftStore, ledger: Ledger, + unloaded_layer_ids: HashSet, } impl FontWorkspace { @@ -89,6 +91,7 @@ impl FontWorkspace { source: WorkspaceSource::Untitled, store, ledger: Ledger::default(), + unloaded_layer_ids: HashSet::new(), }) } @@ -115,6 +118,7 @@ impl FontWorkspace { } pub fn save(&mut self) -> Result<(), WorkspaceError> { + self.acquire_all_layers()?; match self.source.clone() { WorkspaceSource::Package { path } => { self.validate_save_target(&path)?; @@ -130,6 +134,7 @@ impl FontWorkspace { } pub fn save_as(&mut self, source_path: impl AsRef) -> Result<(), WorkspaceError> { + self.acquire_all_layers()?; let source_package = ShiftSourcePackage::save_font_as(source_path, &self.font)?; let identity = source_identity_snapshot(source_package.path())?; self.source = WorkspaceSource::Package { @@ -145,7 +150,11 @@ impl FontWorkspace { let state = store .workspace_state()? .ok_or_else(|| WorkspaceError::CorruptWorkingStore("missing workspace_state".into()))?; - let font = store.load_font_state()?; + let font = store.load_font_directory()?; + let unloaded_layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect(); let source = source_from_workspace_state(&state)?; Ok(Self { @@ -153,6 +162,7 @@ impl FontWorkspace { source, store, ledger: Ledger::default(), + unloaded_layer_ids, }) } @@ -204,7 +214,11 @@ impl FontWorkspace { }) } - pub fn export(&self, request: FontExportRequest) -> Result { + pub fn export( + &mut self, + request: FontExportRequest, + ) -> Result { + self.acquire_all_layers()?; FontExporter::new() .export(&self.font, request) .map_err(WorkspaceError::from) @@ -219,6 +233,28 @@ impl FontWorkspace { set: FontIntentSet, label: Option, ) -> Result { + let mut required_layers = Vec::new(); + for intent in &set.intents { + if let Some(layer_id) = intent.layer_id() { + required_layers.push(layer_id.clone()); + } + match intent { + FontIntent::CloneGlyphLayer { from_layer_id, .. } + | FontIntent::MaterializeGlyphLayer { from_layer_id, .. } => { + required_layers.push(from_layer_id.clone()); + } + FontIntent::DeleteSource { source_id } => { + required_layers.extend(self.font.glyphs().filter_map(|glyph| { + glyph + .layer_for_source(source_id.clone()) + .map(shift_font::GlyphLayer::id) + })); + } + _ => {} + } + } + self.acquire_layers(required_layers)?; + let mut pre = FontLevelPreState::default(); for intent in &set.intents { let Some(layer_id) = intent.layer_id() else { @@ -593,8 +629,11 @@ impl FontWorkspace { next_font: shift_font::Font, change_set: FontChangeSet, ) -> Result<(), WorkspaceError> { - self.store.apply_change_set(&change_set)?; + self.store + .apply_change_set_with_font(&change_set, &next_font)?; self.font = next_font; + self.unloaded_layer_ids + .retain(|layer_id| self.font.layer(layer_id.clone()).is_some()); Ok(()) } @@ -678,6 +717,7 @@ impl FontWorkspace { }, store, ledger: Ledger::default(), + unloaded_layer_ids: HashSet::new(), }) } @@ -702,9 +742,115 @@ impl FontWorkspace { }, store, ledger: Ledger::default(), + unloaded_layer_ids: HashSet::new(), }) } + /// Explicitly acquires requested glyph payloads. When `include_references` + /// is true, component dependencies are expanded from relational indexes + /// before any BLOB is read. + pub fn acquire_glyphs( + &mut self, + glyph_ids: &[GlyphId], + include_references: bool, + ) -> Result<(), WorkspaceError> { + let glyph_ids = if include_references { + self.store + .referenced_glyph_closure(glyph_ids.iter().cloned())? + } else { + glyph_ids.to_vec() + }; + let layer_ids = glyph_ids + .into_iter() + .flat_map(|glyph_id| { + self.font + .glyph(glyph_id) + .into_iter() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect::>() + }) + .collect::>(); + self.acquire_layers(layer_ids) + } + + pub fn acquire_all_layers(&mut self) -> Result<(), WorkspaceError> { + let layer_ids = self.unloaded_layer_ids.iter().cloned().collect::>(); + self.acquire_layers(layer_ids) + } + + fn acquire_layers( + &mut self, + layer_ids: impl IntoIterator, + ) -> Result<(), WorkspaceError> { + let layer_ids = layer_ids + .into_iter() + .filter(|layer_id| self.unloaded_layer_ids.contains(layer_id)) + .collect::>(); + if layer_ids.is_empty() { + return Ok(()); + } + + // Decode and install one layer at a time into a COW font clone. Peak + // temporary memory is one payload, while malformed bytes or a + // cross-layer identity collision still leave the live cache unchanged. + let mut next_font = self.font.clone(); + for layer_id in &layer_ids { + let layer = self.store.load_glyph_layer(layer_id)?.ok_or_else(|| { + WorkspaceError::CorruptWorkingStore(format!("missing payload for layer {layer_id}")) + })?; + next_font.replace_glyph_layer(layer)?; + } + self.font = next_font; + self.unloaded_layer_ids + .retain(|layer_id| !layer_ids.contains(layer_id)); + Ok(()) + } + + /// Drops clean in-memory payloads back to directory placeholders. Every + /// authored edit is committed before the live font swap, so eviction can + /// never discard unpersisted state. + pub fn evict_glyphs(&mut self, glyph_ids: &[GlyphId]) -> Result<(), WorkspaceError> { + let mut next_font = self.font.clone(); + let mut evicted = Vec::new(); + for glyph_id in glyph_ids { + let Some(glyph) = self.font.glyph(glyph_id.clone()) else { + continue; + }; + for layer in glyph.layers().values().map(|layer| layer.as_ref()) { + if self.unloaded_layer_ids.contains(&layer.id()) { + continue; + } + let mut placeholder = + GlyphLayer::with_width(layer.id(), layer.source_id(), layer.width()); + placeholder.set_height(layer.height()); + next_font.replace_glyph_layer(placeholder)?; + evicted.push(layer.id()); + } + } + self.font = next_font; + self.unloaded_layer_ids.extend(evicted); + Ok(()) + } + + pub fn loaded_layer_count(&self) -> usize { + self.font + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::() + .saturating_sub(self.unloaded_layer_ids.len()) + } + + pub fn dependent_glyph_ids_for_layers( + &self, + layer_ids: &[shift_font::LayerId], + ) -> Result, WorkspaceError> { + self.store + .dependent_glyph_ids_for_layers(layer_ids) + .map_err(Into::into) + } + + /// Metadata and directory are always present. Layer payloads are present + /// only after explicit acquisition. pub fn font(&self) -> &shift_font::Font { &self.font } diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 52ead6f8..1315769b 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -237,6 +237,40 @@ fn resume_rebuilds_dirty_untitled_workspace_from_store() { assert!(workspace.font().glyph_id_by_name("A").is_some()); } +#[test] +fn resumed_layers_are_acquired_and_evictable_without_losing_authored_state() { + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("working.sqlite"); + let mut workspace = FontWorkspace::create_untitled(&store_path, NewWorkspace::new()).unwrap(); + let source_id = workspace.font().default_source_id().unwrap(); + let glyph_id = create_glyph(&mut workspace, "A", vec![65]); + let layer_id = create_glyph_layer(&mut workspace, glyph_id.clone(), source_id); + add_square_contour(&mut workspace, &layer_id, (10.0, 20.0), 100.0); + drop(workspace); + + let mut workspace = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(workspace.loaded_layer_count(), 0); + assert!(workspace.font().layer(layer_id.clone()).unwrap().is_empty()); + + workspace + .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .unwrap(); + assert_eq!(workspace.loaded_layer_count(), 1); + let authored = workspace.font().layer(layer_id.clone()).unwrap().clone(); + assert_eq!(authored.contours_iter().next().unwrap().points().len(), 4); + + workspace + .evict_glyphs(std::slice::from_ref(&glyph_id)) + .unwrap(); + assert_eq!(workspace.loaded_layer_count(), 0); + assert!(workspace.font().layer(layer_id.clone()).unwrap().is_empty()); + + workspace + .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .unwrap(); + assert_eq!(workspace.font().layer(layer_id).unwrap(), &authored); +} + #[test] fn resume_package_workspace_can_save_unsaved_store_state() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/architecture/index.md b/docs/architecture/index.md index bd7d5442..1402ff24 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -20,6 +20,7 @@ Central routing table for Shift's distributed documentation. Before creating new | `crates/shift-glyph-codec/**` | [`crates/shift-glyph-codec/SPECIFICATION.md`](../../crates/shift-glyph-codec/SPECIFICATION.md) | Packed glyph framing, validation, and compatibility | | `crates/shift-slug/**` | [`crates/shift-slug/docs/DOCS.md`](../../crates/shift-slug/docs/DOCS.md) | GPU-independent Slug curves, bands, and atlas packing | | `crates/shift-source/**` | [`crates/shift-source/docs/DOCS.md`](../../crates/shift-source/docs/DOCS.md) | User-authored `.shift` source package layout | +| `crates/shift-store/**` | [`crates/shift-store/README.md`](../../crates/shift-store/README.md) | SQLite directory, packed layer payloads, and reference indexes | | `crates/shift-workspace/**` | [`crates/shift-workspace/docs/DOCS.md`](../../crates/shift-workspace/docs/DOCS.md) | Open font workspace runtime over source, store, and font | | `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | From 25bc76c6784804e031e72d3902574e60b51af234 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Mon, 27 Jul 2026 08:18:32 +0000 Subject: [PATCH 03/29] Add packed layer golden and import stress tests --- crates/shift-store/src/packed_layer.rs | 40 +++++ .../shift-workspace/tests/workspace_test.rs | 140 ++++++++++++++++++ 2 files changed, 180 insertions(+) diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index b3e16549..35946426 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -459,6 +459,46 @@ mod tests { (store, font) } + #[test] + fn sqlite_payload_matches_shared_canonical_golden_bytes() { + let golden = include_bytes!("../../../fixtures/glyph-codec/layer-v1/full.bin"); + let layer = font::unpack_glyph_layer(golden).unwrap(); + let glyph_id = font::GlyphId::from_raw("storage_golden"); + let glyph_name = font::GlyphName::from("storageGolden"); + let mut store = ShiftStore::open_memory_for_test().unwrap(); + store + .conn + .execute( + "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'Golden', 'master', 0)", + [layer.source_id().to_string()], + ) + .unwrap(); + store + .conn + .execute( + "INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, 0)", + params![glyph_id.to_string(), glyph_name.as_str()], + ) + .unwrap(); + + store + .layer_stream_writer() + .write_layer(&glyph_id, Some(&glyph_name), &layer) + .unwrap(); + + let stored: Vec = store + .conn + .query_row( + "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored.as_slice(), golden); + let decoded = store.load_glyph_layer(&layer.id()).unwrap().unwrap(); + assert_eq!(font::pack_glyph_layer(&decoded).unwrap().as_bytes(), golden); + } + #[test] fn directory_open_never_reads_malformed_payloads() { let (store, font) = populated_store(); diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 1315769b..73538d07 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -147,6 +147,146 @@ fn imports_external_fonts_without_a_save_target() { ); } +#[test] +fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("apps/desktop/src/renderer/src/assets/fonts/Inter-VariableFont.ttf"); + let store_path = temp.path().join("inter.sqlite"); + + let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let glyph_count = workspace.font().glyph_count(); + let total_layers = workspace + .font() + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::(); + assert!( + glyph_count > 2_000, + "fixture should exercise a large glyph directory" + ); + assert!(total_layers >= glyph_count); + + let requested = workspace + .font() + .glyphs() + .filter(|glyph| !glyph.layers().is_empty()) + .take(8) + .map(|glyph| glyph.id()) + .collect::>(); + let expected = requested + .iter() + .flat_map(|glyph_id| { + workspace + .font() + .glyph(glyph_id.clone()) + .unwrap() + .layers() + .values() + .map(|layer| { + ( + layer.id(), + shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), + ) + }) + .collect::>() + }) + .collect::>(); + drop(workspace); + + let mut resumed = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(resumed.font().glyph_count(), glyph_count); + assert_eq!(resumed.loaded_layer_count(), 0); + + resumed.acquire_glyphs(&requested, false).unwrap(); + + assert_eq!(resumed.loaded_layer_count(), expected.len()); + assert!(resumed.loaded_layer_count() < total_layers); + for (layer_id, bytes) in expected { + assert_eq!( + shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) + .unwrap() + .as_bytes(), + bytes + ); + } +} + +#[test] +fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); + let store_path = temp.path().join("mutatorsans.sqlite"); + + let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let glyph_count = workspace.font().glyph_count(); + let total_layers = workspace + .font() + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::(); + assert!(workspace.font().sources().len() >= 4); + assert!(total_layers >= glyph_count * 4); + + let root = workspace + .font() + .glyphs() + .find(|glyph| { + glyph + .layers() + .values() + .any(|layer| !layer.components().is_empty()) + }) + .expect("fixture should contain a composite glyph") + .id(); + let closure = workspace + .store() + .referenced_glyph_closure([root.clone()]) + .unwrap(); + assert!(closure.len() > 1); + let closure_layer_count = closure + .iter() + .map(|glyph_id| { + workspace + .font() + .glyph(glyph_id.clone()) + .unwrap() + .layers() + .len() + }) + .sum::(); + let expected_root_layers = workspace + .font() + .glyph(root.clone()) + .unwrap() + .layers() + .values() + .map(|layer| { + ( + layer.id(), + shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), + ) + }) + .collect::>(); + drop(workspace); + + let mut resumed = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(resumed.loaded_layer_count(), 0); + resumed + .acquire_glyphs(std::slice::from_ref(&root), true) + .unwrap(); + + assert_eq!(resumed.loaded_layer_count(), closure_layer_count); + assert!(resumed.loaded_layer_count() < total_layers); + for (layer_id, bytes) in expected_root_layers { + assert_eq!( + shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) + .unwrap() + .as_bytes(), + bytes + ); + } +} + #[test] fn save_requires_save_as_for_untitled_workspaces() { let temp = tempfile::tempdir().unwrap(); From 12d56cf4882bf69e8d75e3628a0fb0b6c96df3ac Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 06:48:53 +0000 Subject: [PATCH 04/29] Stream foreign fonts into lazy SQLite layers --- Cargo.lock | 2 + crates/shift-backends/Cargo.toml | 1 + crates/shift-backends/docs/DOCS.md | 23 +- crates/shift-backends/src/binary/mod.rs | 2 + crates/shift-backends/src/binary/reader.rs | 223 +++++++-- .../shift-backends/src/designspace/import.rs | 435 +++++++++++++++++ crates/shift-backends/src/designspace/mod.rs | 2 + .../shift-backends/src/designspace/reader.rs | 28 +- crates/shift-backends/src/font_loader.rs | 30 ++ crates/shift-backends/src/import.rs | 91 ++++ crates/shift-backends/src/lib.rs | 2 + crates/shift-backends/src/ufo/import.rs | 294 ++++++++++++ crates/shift-backends/src/ufo/mod.rs | 2 + crates/shift-backends/src/ufo/reader.rs | 199 ++++---- crates/shift-backends/tests/loading.rs | 89 ++++ crates/shift-font/docs/DOCS.md | 2 +- crates/shift-font/src/ir/font.rs | 119 +++-- crates/shift-store/Cargo.toml | 1 + crates/shift-store/README.md | 12 +- crates/shift-store/src/change_set.rs | 259 +++++++---- crates/shift-store/src/connection.rs | 73 ++- crates/shift-store/src/error.rs | 12 + crates/shift-store/src/lib.rs | 5 +- crates/shift-store/src/packed_layer.rs | 440 +++++++++++++++--- crates/shift-store/src/store.rs | 1 + crates/shift-store/src/stream_writer.rs | 130 +++++- crates/shift-store/tests/store_test.rs | 47 ++ crates/shift-workspace/docs/DOCS.md | 19 +- .../examples/profile_streaming_import.rs | 159 +++++++ crates/shift-workspace/src/workspace.rs | 118 ++++- .../shift-workspace/tests/workspace_test.rs | 21 +- 31 files changed, 2462 insertions(+), 379 deletions(-) create mode 100644 crates/shift-backends/src/designspace/import.rs create mode 100644 crates/shift-backends/src/import.rs create mode 100644 crates/shift-backends/src/ufo/import.rs create mode 100644 crates/shift-workspace/examples/profile_streaming_import.rs diff --git a/Cargo.lock b/Cargo.lock index 3d712c88..fe681faf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2187,6 +2187,7 @@ dependencies = [ "ordered-float", "plist", "quick-xml 0.37.5", + "rayon", "serde", "shift-font", "shift-source", @@ -2282,6 +2283,7 @@ dependencies = [ name = "shift-store" version = "0.1.0" dependencies = [ + "rayon", "rusqlite", "serde_json", "shift-font", diff --git a/crates/shift-backends/Cargo.toml b/crates/shift-backends/Cargo.toml index 6fa0dfc0..81357be0 100644 --- a/crates/shift-backends/Cargo.toml +++ b/crates/shift-backends/Cargo.toml @@ -22,6 +22,7 @@ kurbo = "0.12" ordered-float = "5" plist = "1" quick-xml = { version = "0.37.5", features = ["serialize"] } +rayon = "1.10" serde = "1" tempfile = "3" thiserror = "2.0.18" diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index f01836ce..e2b0c364 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -4,11 +4,11 @@ Font format backends that convert between on-disk font files and the `Font` IR u ## Architecture Invariants -**Architecture Invariant:** All backends convert to/from `Font` (the shift-font representation), never exposing format-specific types (norad, glyphs-reader) to callers. WHY: The rest of the editor operates on a single IR; leaking format types would couple the editor to specific file formats. +**Architecture Invariant:** All backends convert to/from `shift-font` values, never exposing format-specific types (norad, glyphs-reader) to callers. Eager readers return `Font`; bounded foreign imports return a glyph-free `Font` header plus batches of owned `Glyph` values. WHY: The rest of the editor operates on one authored model while large imports avoid constructing the complete model at once. **Architecture Invariant:** `FontReader` and `FontWriter` require `Send + Sync`. WHY: Backends are stored in `FontLoader` which lives inside the editor's shared state; they must be safe to use from multiple threads. -**Architecture Invariant:** Backends are stateless unit structs (no fields). WHY: They are pure converters with no caching or mutable state, making them trivially thread-safe and cheap to construct. +**Architecture Invariant:** Eager reader/writer backends are stateless unit structs. A `FontImport` owns only the foreign bytes or GLIF directory records and its current cursor. WHY: ordinary conversion stays pure, while bounded imports retain only the state needed to produce the next batch. **Architecture Invariant:** `UfoWriter` stages a complete UFO beside the destination and swaps it into place only after the staged tree is durable. WHY: a failed save must preserve the previous source rather than leave a partial directory. @@ -20,23 +20,31 @@ Font format backends that convert between on-disk font files and the `Font` IR u **Architecture Invariant:** TrueType export compiles an owned snapshot of the Shift `Font` IR directly through fontir/fontc. It must not serialize a temporary UFO or fall back to another authoring format. WHY: `.shift` is the canonical authoring source, and an intermediate format would discard or reinterpret Shift concepts before compilation. +**Architecture Invariant:** TTF/OTF, UFO, and Designspace streaming imports parse glyphs in bounded Rayon batches and preserve input order when publishing each batch. SQLite remains outside this crate and is written by one workspace-owned sink. WHY: outline/GLIF parsing and canonical packing are parallel work, while concurrent SQLite authors would add contention and weaken transaction ownership. + +**Architecture Invariant:** Compiled-font streaming enumerates `maxp` glyph IDs, not only `cmap` mappings. Unencoded glyphs receive their `post`/CFF name or a synthesized `gidN` name, and all Unicode mappings for a glyph share one authored glyph identity. WHY: `cmap` is character lookup, not the complete glyph directory. + **Architecture Invariant:** Designspace source locations are imported as complete design-space locations. An omitted source dimension resolves to that axis's user-space default mapped into design space; default-source selection compares against that same completed mapped location and never silently substitutes the first source. A `layer` attribute only selects where a source's outlines live and does not make it ineligible to be the default. WHY: these are the reference designspaceLib semantics, and mixing user defaults with design coordinates corrupts interpolation bases. ## Codemap ``` src/ - lib.rs -- re-exports FontReader, FontWriter, FontBackend, and sub-modules + lib.rs -- re-exports FontReader, FontWriter, FontBackend, FontImport, and sub-modules + import.rs -- glyph-free foreign header plus bounded glyph-batch cursor traits.rs -- FontReader, FontWriter, FontBackend trait definitions ufo/ mod.rs -- UfoBackend convenience struct combining reader+writer; round-trip tests + import.rs -- metadata/directory-first GLIF batches without a complete norad or Shift font reader.rs -- UfoReader: norad::Font -> shift_font::Font writer.rs -- UfoWriter: shift_font::Font -> atomically written norad::Font glyphs/ mod.rs -- GlyphsReader re-export; fixture-based integration tests reader.rs -- GlyphsReader: glyphs_reader::Font -> shift_font::Font (read-only) + designspace/ + import.rs -- source directories plus glyph-major, sparse multi-source GLIF batches binary/ - reader.rs -- TTF/OTF outlines, metrics, names, and fvar metadata -> shift_font::Font + reader.rs -- eager and maxp-complete bounded TTF/OTF conversion shift2fontir/ source.rs -- owned Shift FontView snapshot and fontir Source implementation axes.rs -- Shift axis/mapping conversion and source normalization @@ -49,6 +57,9 @@ src/ ## Key Types +- `FontImport` -- top-level authored header, an immediately publishable stable-ID/name directory, and layer-aware `next_batch(limit)`, with no glyphs stored in the header +- `GlyphDirectoryEntry` -- cheap foreign glyph ID and name used before geometry batches are parsed +- `ImportBatchLimit` -- simultaneous glyph and authored-layer limits; multi-source projects cannot turn a glyph-count bound into an unbounded layer batch - `FontReader` -- trait with `load(&self, path) -> Result` plus default methods for extracting glyphs, kerning, features from a loaded `Font` - `FontWriter` -- trait with `save(&self, font, path) -> Result<(), String>` - `FontBackend` -- auto-implemented marker trait for types implementing both `FontReader` + `FontWriter` @@ -61,13 +72,13 @@ src/ ## How it works -**Loading a font:** `FontLoader` (in shift-core) dispatches by file extension to the appropriate backend. The backend reads the file using a format-specific library (`norad` for UFO, `glyphs-reader` for Glyphs), then walks the parsed data to build a `Font`. This involves converting point types, contours, components, anchors, guidelines, kerning groups/pairs, OpenType features, and lib data into their IR equivalents. +**Loading a font:** `FontLoader::read_font` retains the eager compatibility path. `FontLoader::stream_font` dispatches TTF/OTF, UFO, and Designspace sources to bounded importers. It first returns complete top-level metadata and a cheap glyph/source directory, then materializes at most the requested batch of `Glyph` values. Designspace source headers and layer directories are read in parallel and then published in stable source order. Rayon converts geometry records in parallel; indexed collection preserves glyph order. The workspace writes and releases each batch before requesting another. **Point type mapping (read):** norad uses separate `Move`, `Line`, `Curve`, `OffCurve`, `QCurve` types. The IR collapses `Move`/`Line`/`Curve` into `OnCurve` and keeps `OffCurve` and `QCurve` distinct. On write, context (position in contour, open/closed, preceding point type) is used to reconstruct the correct norad variant. **Multi-layer support:** `UfoReader` iterates all norad layers. The `public.default` layer maps to the IR's default layer; other layers are added via `Font::add_layer`. Glyphs in non-default layers are merged into existing `Glyph` entries when the glyph already exists from another layer. -**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. +**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID and groups all `cmap` values by glyph. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. **Glyphs-format specifics:** `GlyphsReader` also extracts axes, sources, and per-master locations -- data that UFO does not natively represent. Kerning group membership is derived from per-glyph `right_kern`/`left_kern` fields and normalized to `public.kern1.*`/`public.kern2.*` conventions. diff --git a/crates/shift-backends/src/binary/mod.rs b/crates/shift-backends/src/binary/mod.rs index bcb8530c..cbd75cd0 100644 --- a/crates/shift-backends/src/binary/mod.rs +++ b/crates/shift-backends/src/binary/mod.rs @@ -1,5 +1,7 @@ mod reader; +pub(crate) use reader::stream_font_file; + use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::font_loader::FontAdaptor; use shift_font::Font; diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index 2dfa9e2b..0c671d3a 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -1,11 +1,18 @@ -use crate::errors::{FormatBackendError, FormatBackendResult}; +use std::sync::Arc; + +use crate::{ + errors::{FormatBackendError, FormatBackendResult}, + import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, +}; +use rayon::prelude::*; use shift_font::{ - Axis, Contour, Font, Glyph, GlyphLayer, LayerId, Location, MetricKind, NamedInstance, PointType, + Axis, Contour, Font, Glyph, GlyphId as ShiftGlyphId, GlyphLayer, GlyphName, LayerId, Location, + MetricKind, NamedInstance, PointType, SourceId, }; use skrifa::{ outline::{DrawSettings, OutlinePen}, prelude::{LocationRef, Size}, - raw::{ReadError, TableProvider}, + raw::{types::GlyphId, ReadError, TableProvider}, string::StringId, FontRef, MetadataProvider, }; @@ -155,6 +162,54 @@ fn font_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { .hmtx() .map_err(|e| FormatBackendError::Binary(format!("failed to read hmtx table: {e}")))?; + let mut ir_font = font_header_from_skrifa(font)?; + let default_source_id = ir_font + .default_source_id() + .expect("new font should have a default source"); + + for (unicode, glyph_id) in char_map.mappings() { + let outline = outlines.get(glyph_id).ok_or_else(|| { + FormatBackendError::Binary(format!( + "missing outline for glyph {glyph_id} (U+{unicode:04X})" + )) + })?; + let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); + let mut pen = ShiftPen::default(); + outline.draw(settings, &mut pen).map_err(|e| { + FormatBackendError::Binary(format!( + "failed to draw outline for glyph {glyph_id} (U+{unicode:04X}): {e}" + )) + })?; + + let advance_width = hmtx.advance(glyph_id).ok_or_else(|| { + FormatBackendError::Binary(format!( + "missing advance width for glyph {glyph_id} (U+{unicode:04X})" + )) + })?; + + let glyph_name = char::from_u32(unicode) + .map(|c| c.to_string()) + .unwrap_or_else(|| format!("uni{unicode:04X}")); + + let mut glyph = Glyph::with_unicode(glyph_name, unicode); + let mut layer = GlyphLayer::with_width( + LayerId::new(), + default_source_id.clone(), + advance_width as f64, + ); + let mut contours = pen.contours(); + detect_smooth_points(&mut contours); + for contour in contours { + layer.add_contour(contour); + } + glyph.set_layer(layer); + ir_font.insert_glyph(glyph)?; + } + + Ok(ir_font) +} + +fn font_header_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { let metrics = font.metrics(Size::unscaled(), LocationRef::default()); let mut ir_font = Font::new(); let default_source_id = ir_font @@ -164,7 +219,7 @@ fn font_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { ir_font.metrics_mut().units_per_em = metrics.units_per_em as f64; let metric_definitions = ir_font.metric_definitions().to_vec(); let default_source = ir_font - .source_mut(default_source_id.clone()) + .source_mut(default_source_id) .expect("new font should contain its default source"); set_metric_position( &metric_definitions, @@ -199,47 +254,153 @@ fn font_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { } load_variation_metadata(font, &mut ir_font)?; + Ok(ir_font) +} - for (unicode, glyph_id) in char_map.mappings() { - let outline = outlines.get(glyph_id).ok_or_else(|| { - FormatBackendError::Binary(format!( - "missing outline for glyph {glyph_id} (U+{unicode:04X})" - )) +#[derive(Clone)] +struct BinaryGlyphRecord { + raw_id: GlyphId, + shift_id: ShiftGlyphId, + name: GlyphName, + unicodes: Vec, +} + +pub(crate) struct BinaryGlyphStream { + bytes: Arc<[u8]>, + source_id: SourceId, + glyphs: Vec, + next_glyph: usize, +} + +pub(crate) fn stream_font_file(path: &str) -> FormatBackendResult<(Font, BinaryGlyphStream)> { + let bytes: Arc<[u8]> = std::fs::read(path) + .map_err(|error| FormatBackendError::Binary(format!("failed to read '{path}': {error}")))? + .into(); + let font = FontRef::new(bytes.as_ref()).map_err(|error| { + FormatBackendError::Binary(format!("failed to parse '{path}': {error}")) + })?; + font.hmtx().map_err(|error| { + FormatBackendError::Binary(format!("failed to read hmtx table: {error}")) + })?; + let header = font_header_from_skrifa(&font)?; + let source_id = header + .default_source_id() + .expect("binary font header should have a default source"); + let glyph_count = font + .maxp() + .map_err(|error| FormatBackendError::Binary(format!("failed to read maxp table: {error}")))? + .num_glyphs() as usize; + let mut unicodes = vec![Vec::new(); glyph_count]; + for (unicode, glyph_id) in font.charmap().mappings() { + if let Some(values) = unicodes.get_mut(glyph_id.to_u32() as usize) { + values.push(unicode); + } + } + let names = font.glyph_names(); + let glyphs = unicodes + .into_iter() + .enumerate() + .map(|(index, unicodes)| { + let raw_id = GlyphId::new(index as u32); + let name = names + .get(raw_id) + .map(|name| name.to_string()) + .unwrap_or_else(|| format!("gid{index}")); + BinaryGlyphRecord { + raw_id, + shift_id: ShiftGlyphId::from_raw(format!("binary{index}")), + name: GlyphName::from(name), + unicodes, + } + }) + .collect(); + + Ok(( + header, + BinaryGlyphStream { + bytes, + source_id, + glyphs, + next_glyph: 0, + }, + )) +} + +impl GlyphStream for BinaryGlyphStream { + fn directory(&self) -> Vec { + self.glyphs + .iter() + .map(|glyph| GlyphDirectoryEntry { + glyph_id: glyph.shift_id.clone(), + name: glyph.name.clone(), + }) + .collect() + } + + fn glyph_count(&self) -> usize { + self.glyphs.len() + } + + fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { + if self.next_glyph == self.glyphs.len() { + return Ok(Vec::new()); + } + + let batch_glyphs = limit.max_glyphs().min(limit.max_layers()); + let end = self + .next_glyph + .saturating_add(batch_glyphs) + .min(self.glyphs.len()); + let font = FontRef::new(self.bytes.as_ref()).map_err(|error| { + FormatBackendError::Binary(format!("failed to reopen resident font: {error}")) })?; + let source_id = &self.source_id; + let glyphs = self.glyphs[self.next_glyph..end] + .par_iter() + .map(|record| glyph_from_skrifa(&font, source_id, record)) + .collect::>>()?; + self.next_glyph = end; + Ok(glyphs) + } +} + +fn glyph_from_skrifa( + font: &FontRef<'_>, + source_id: &SourceId, + record: &BinaryGlyphRecord, +) -> FormatBackendResult { + let hmtx = font.hmtx().map_err(|error| { + FormatBackendError::Binary(format!("failed to read hmtx table: {error}")) + })?; + let advance_width = hmtx.advance(record.raw_id).ok_or_else(|| { + FormatBackendError::Binary(format!("missing advance width for glyph {}", record.raw_id)) + })?; + let mut layer = GlyphLayer::with_width( + LayerId::from_raw(format!("binary{}", record.raw_id.to_u32())), + source_id.clone(), + advance_width as f64, + ); + + if let Some(outline) = font.outline_glyphs().get(record.raw_id) { let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); let mut pen = ShiftPen::default(); - outline.draw(settings, &mut pen).map_err(|e| { + outline.draw(settings, &mut pen).map_err(|error| { FormatBackendError::Binary(format!( - "failed to draw outline for glyph {glyph_id} (U+{unicode:04X}): {e}" + "failed to draw outline for glyph {}: {error}", + record.raw_id )) })?; - - let advance_width = hmtx.advance(glyph_id).ok_or_else(|| { - FormatBackendError::Binary(format!( - "missing advance width for glyph {glyph_id} (U+{unicode:04X})" - )) - })?; - - let glyph_name = char::from_u32(unicode) - .map(|c| c.to_string()) - .unwrap_or_else(|| format!("uni{unicode:04X}")); - - let mut glyph = Glyph::with_unicode(glyph_name, unicode); - let mut layer = GlyphLayer::with_width( - LayerId::new(), - default_source_id.clone(), - advance_width as f64, - ); let mut contours = pen.contours(); detect_smooth_points(&mut contours); for contour in contours { layer.add_contour(contour); } - glyph.set_layer(layer); - ir_font.insert_glyph(glyph)?; } - Ok(ir_font) + let mut glyph = Glyph::with_id(record.shift_id.clone(), record.name.clone()); + glyph.set_unicodes(record.unicodes.clone()); + glyph.set_layer(layer); + Ok(glyph) } fn load_variation_metadata(font: &FontRef<'_>, ir_font: &mut Font) -> FormatBackendResult<()> { diff --git a/crates/shift-backends/src/designspace/import.rs b/crates/shift-backends/src/designspace/import.rs new file mode 100644 index 00000000..538e6e84 --- /dev/null +++ b/crates/shift-backends/src/designspace/import.rs @@ -0,0 +1,435 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +use norad::designspace::DesignSpaceDocument; +use rayon::prelude::*; +use shift_font::{Axis, AxisId, Font, Glyph, GlyphId, LayerId, Location, Source, SourceId}; + +use crate::{ + errors::{FormatBackendError, FormatBackendResult}, + import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, + metrics::copy_source_metrics, + ufo::{load_header, read_glyph_paths, UfoReader}, +}; + +use super::{ + axis_labels, + error::{DesignspaceError, DesignspaceResult}, + reader::{ + axis_mappings_from_designspace, axisless_source_name, derive_axis_range, + find_default_source_index, location_from_dimensions, named_instances_from_designspace, + parse_axisless_sources, source_name, + }, +}; + +struct SourceDirectory { + source_id: SourceId, + glyphs: BTreeMap, +} + +#[derive(Clone)] +struct SourceGlyph { + source_id: SourceId, + layer_id: LayerId, + path: PathBuf, +} + +#[derive(Clone)] +struct GlyphRecord { + id: GlyphId, + name: String, + layers: Vec, +} + +pub(crate) struct DesignspaceGlyphStream { + glyph_ids: HashMap, + glyphs: Vec, + next_glyph: usize, +} + +pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, DesignspaceGlyphStream)> { + stream_designspace(Path::new(path)).map_err(FormatBackendError::from) +} + +fn stream_designspace( + designspace_path: &Path, +) -> DesignspaceResult<(Font, DesignspaceGlyphStream)> { + let designspace_dir = + designspace_path + .parent() + .ok_or_else(|| DesignspaceError::MissingParent { + path: designspace_path.to_path_buf(), + })?; + let xml = + fs::read_to_string(designspace_path).map_err(|source| DesignspaceError::ReadFile { + path: designspace_path.to_path_buf(), + source, + })?; + let mut labels = axis_labels::parse(&xml)?; + let document = match DesignSpaceDocument::load(designspace_path) { + Ok(document) => document, + Err(error) => { + return stream_axisless_designspace( + designspace_path, + designspace_dir, + &xml, + &error.to_string(), + ); + } + }; + if document.sources.is_empty() { + return Err(DesignspaceError::NoSources); + } + let default_index = + find_default_source_index(&document).ok_or(DesignspaceError::MissingDefaultSource)?; + let default_descriptor = &document.sources[default_index]; + let default_path = designspace_dir.join(&default_descriptor.filename); + let mut header = load_header(&default_path).map_err(|error| DesignspaceError::LoadUfo { + path: default_path.clone(), + details: error.to_string(), + })?; + let default_metrics = header + .default_source() + .cloned() + .ok_or(DesignspaceError::NoSources)?; + header.clear_sources(); + + if let Some(family_name) = &default_descriptor.familyname { + header.metadata_mut().family_name = Some(family_name.clone()); + } + add_axes(&mut header, &document, &mut labels)?; + header.set_axis_mappings(axis_mappings_from_designspace(&document, header.axes())?)?; + header.set_named_instances(named_instances_from_designspace(&document, header.axes())?)?; + + let source_order = std::iter::once(default_index) + .chain((0..document.sources.len()).filter(|index| *index != default_index)) + .collect::>(); + let default_style_name = header.metadata().style_name.clone(); + let source_materials = source_order + .into_par_iter() + .map(|index| { + let descriptor = &document.sources[index]; + let ufo_path = designspace_dir.join(&descriptor.filename); + let (style_name, imported_metrics) = if index == default_index { + (default_style_name.clone(), default_metrics.clone()) + } else { + let ufo_header = + load_header(&ufo_path).map_err(|error| DesignspaceError::LoadUfo { + path: ufo_path.clone(), + details: error.to_string(), + })?; + let metrics = ufo_header + .default_source() + .cloned() + .ok_or(DesignspaceError::NoSources)?; + (ufo_header.metadata().style_name.clone(), metrics) + }; + let glyphs = + read_glyph_paths(&ufo_path, descriptor.layer.as_deref()).map_err(|error| { + DesignspaceError::LoadUfo { + path: ufo_path, + details: error.to_string(), + } + })?; + Ok((index, style_name, imported_metrics, glyphs)) + }) + .collect::>>()?; + + let mut source_names = HashSet::new(); + let mut directories = Vec::with_capacity(document.sources.len()); + for (index, style_name, imported_metrics, glyphs) in source_materials { + let descriptor = &document.sources[index]; + let name = source_name(descriptor, style_name.as_deref(), &source_names, index); + source_names.insert(name.clone()); + let location = location_from_dimensions(&descriptor.location, &document, header.axes()); + let mut source = Source::with_filename(name, location, descriptor.filename.clone()); + source.set_layer_name(descriptor.layer.clone()); + let metric_definitions = header.metric_definitions().to_vec(); + copy_source_metrics( + &metric_definitions, + &imported_metrics, + &metric_definitions, + &mut source, + ); + let source_id = header.add_source(source); + if index == default_index { + header.set_default_source_id(source_id.clone()); + } + directories.push(SourceDirectory { source_id, glyphs }); + } + + let (glyph_ids, glyphs) = build_glyph_directory(&directories, 0); + Ok(( + header, + DesignspaceGlyphStream { + glyph_ids, + glyphs, + next_glyph: 0, + }, + )) +} + +fn stream_axisless_designspace( + designspace_path: &Path, + designspace_dir: &Path, + xml: &str, + original_error: &str, +) -> DesignspaceResult<(Font, DesignspaceGlyphStream)> { + let sources = parse_axisless_sources(xml).map_err(|fallback_error| { + DesignspaceError::LoadDesignspace { + path: designspace_path.to_path_buf(), + details: format!( + "{fallback_error}; axisless fallback was used after parser error: {original_error}" + ), + } + })?; + if sources.is_empty() { + return Err(DesignspaceError::NoSources); + } + + let default_path = designspace_dir.join(&sources[0].filename); + let mut header = load_header(&default_path).map_err(|error| DesignspaceError::LoadUfo { + path: default_path, + details: error.to_string(), + })?; + if let Some(family_name) = &sources[0].familyname { + header.metadata_mut().family_name = Some(family_name.clone()); + } + let default_style_name = header.metadata().style_name.clone(); + let default_metrics = header + .default_source() + .cloned() + .ok_or(DesignspaceError::NoSources)?; + let default_metric_definitions = header.metric_definitions().to_vec(); + header.clear_sources(); + + let source_materials = sources + .par_iter() + .enumerate() + .map(|(index, descriptor)| { + let ufo_path = designspace_dir.join(&descriptor.filename); + let (style_name, metric_definitions, imported_metrics) = if index == 0 { + ( + default_style_name.clone(), + default_metric_definitions.clone(), + default_metrics.clone(), + ) + } else { + let ufo_header = + load_header(&ufo_path).map_err(|error| DesignspaceError::LoadUfo { + path: ufo_path.clone(), + details: error.to_string(), + })?; + let metrics = ufo_header + .default_source() + .cloned() + .ok_or(DesignspaceError::NoSources)?; + ( + ufo_header.metadata().style_name.clone(), + ufo_header.metric_definitions().to_vec(), + metrics, + ) + }; + let glyphs = + read_glyph_paths(&ufo_path, descriptor.layer.as_deref()).map_err(|error| { + DesignspaceError::LoadUfo { + path: ufo_path, + details: error.to_string(), + } + })?; + Ok(( + index, + style_name, + metric_definitions, + imported_metrics, + glyphs, + )) + }) + .collect::>>()?; + + let mut source_names = HashSet::new(); + let mut directories = Vec::with_capacity(sources.len()); + for (index, style_name, metric_definitions, imported_metrics, glyphs) in source_materials { + let descriptor = &sources[index]; + let name = axisless_source_name(descriptor, style_name.as_deref(), &source_names, index); + source_names.insert(name.clone()); + let mut source = Source::with_filename(name, Location::new(), descriptor.filename.clone()); + source.set_layer_name(descriptor.layer.clone()); + copy_source_metrics( + &metric_definitions, + &imported_metrics, + header.metric_definitions(), + &mut source, + ); + let source_id = header.add_source(source); + if index == 0 { + header.set_default_source_id(source_id.clone()); + } + directories.push(SourceDirectory { source_id, glyphs }); + } + + let (glyph_ids, glyphs) = build_glyph_directory(&directories, 0); + Ok(( + header, + DesignspaceGlyphStream { + glyph_ids, + glyphs, + next_glyph: 0, + }, + )) +} + +impl GlyphStream for DesignspaceGlyphStream { + fn directory(&self) -> Vec { + self.glyphs + .iter() + .map(|glyph| GlyphDirectoryEntry { + glyph_id: glyph.id.clone(), + name: glyph.name.clone().into(), + }) + .collect() + } + + fn glyph_count(&self) -> usize { + self.glyphs.len() + } + + fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { + if self.next_glyph == self.glyphs.len() { + return Ok(Vec::new()); + } + let mut end = self.next_glyph; + let mut layer_count = 0; + while end < self.glyphs.len() && end - self.next_glyph < limit.max_glyphs() { + let next_layers = self.glyphs[end].layers.len(); + if end > self.next_glyph && layer_count + next_layers > limit.max_layers() { + break; + } + layer_count += next_layers; + end += 1; + } + let glyph_ids = &self.glyph_ids; + let glyphs = self.glyphs[self.next_glyph..end] + .par_iter() + .map(|record| load_glyph(record, glyph_ids)) + .collect::>>()?; + self.next_glyph = end; + Ok(glyphs) + } +} + +fn add_axes( + header: &mut Font, + document: &DesignSpaceDocument, + labels: &mut HashMap>, +) -> DesignspaceResult<()> { + for axis in &document.axes { + let mut imported = if let Some(values) = &axis.values { + let mut values = values + .iter() + .map(|value| f64::from(*value)) + .collect::>(); + values.sort_by(f64::total_cmp); + values.dedup(); + Axis::discrete_with_id( + AxisId::new(), + axis.tag.clone(), + axis.name.clone(), + values, + f64::from(axis.default), + ) + } else { + let (minimum, maximum) = derive_axis_range(axis); + Axis::new( + axis.tag.clone(), + axis.name.clone(), + minimum, + f64::from(axis.default), + maximum, + ) + }; + imported.set_hidden(axis.hidden); + imported.set_labels(labels.remove(axis.name.as_str()).unwrap_or_default()); + imported.validate()?; + header.add_axis(imported)?; + } + Ok(()) +} + +fn build_glyph_directory( + directories: &[SourceDirectory], + default_index: usize, +) -> (HashMap, Vec) { + let names = directories[default_index] + .glyphs + .keys() + .cloned() + .collect::>(); + let glyph_ids = names + .iter() + .map(|name| (name.clone(), GlyphId::new())) + .collect::>(); + let glyphs = names + .into_iter() + .map(|name| { + let mut layers = Vec::new(); + if let Some(path) = directories[default_index].glyphs.get(&name) { + layers.push(SourceGlyph { + source_id: directories[default_index].source_id.clone(), + layer_id: LayerId::new(), + path: path.clone(), + }); + } + for (index, directory) in directories.iter().enumerate() { + if index == default_index { + continue; + } + if let Some(path) = directory.glyphs.get(&name) { + layers.push(SourceGlyph { + source_id: directory.source_id.clone(), + layer_id: LayerId::new(), + path: path.clone(), + }); + } + } + GlyphRecord { + id: glyph_ids[&name].clone(), + name, + layers, + } + }) + .collect(); + (glyph_ids, glyphs) +} + +fn load_glyph( + record: &GlyphRecord, + glyph_ids: &HashMap, +) -> FormatBackendResult { + let mut glyph = Glyph::with_id(record.id.clone(), record.name.clone()); + for (index, source) in record.layers.iter().enumerate() { + let norad_glyph = norad::Glyph::load(&source.path).map_err(|error| { + FormatBackendError::Ufo(format!( + "failed to read glyph {:?} from {}: {error}", + record.name, + source.path.display() + )) + })?; + if index == 0 { + glyph.set_unicodes(norad_glyph.codepoints.iter().map(u32::from).collect()); + if !norad_glyph.lib.is_empty() { + *glyph.lib_mut() = UfoReader::convert_lib(&norad_glyph.lib); + } + } + glyph.set_layer(UfoReader::convert_stream_layer( + &norad_glyph, + source.layer_id.clone(), + source.source_id.clone(), + glyph_ids, + )?); + } + Ok(glyph) +} diff --git a/crates/shift-backends/src/designspace/mod.rs b/crates/shift-backends/src/designspace/mod.rs index 11359580..f36849d0 100644 --- a/crates/shift-backends/src/designspace/mod.rs +++ b/crates/shift-backends/src/designspace/mod.rs @@ -1,9 +1,11 @@ mod axis_labels; mod error; +mod import; mod reader; mod writer; pub use error::DesignspaceError; +pub(crate) use import::stream_font; pub use reader::DesignspaceReader; pub use writer::DesignspaceWriter; diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index 1e230299..8eca892c 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -242,7 +242,7 @@ impl DesignspaceReader { } } -fn named_instances_from_designspace( +pub(super) fn named_instances_from_designspace( doc: &DesignSpaceDocument, axes: &[Axis], ) -> DesignspaceResult> { @@ -361,7 +361,7 @@ fn unmap_axis_value(axis: &norad::designspace::Axis, value: f64) -> f64 { value + last_user - last_design } -fn axis_mappings_from_designspace( +pub(super) fn axis_mappings_from_designspace( doc: &DesignSpaceDocument, axes: &[Axis], ) -> DesignspaceResult> { @@ -463,12 +463,12 @@ fn extend_axis_ids(target: &mut Vec, location: &Location) { } #[derive(Clone, Debug)] -struct AxislessSource { - filename: String, - familyname: Option, - stylename: Option, - name: Option, - layer: Option, +pub(super) struct AxislessSource { + pub(super) filename: String, + pub(super) familyname: Option, + pub(super) stylename: Option, + pub(super) name: Option, + pub(super) layer: Option, } fn load_axisless_designspace( @@ -625,7 +625,7 @@ fn load_axisless_designspace( Ok(font) } -fn axisless_source_name( +pub(super) fn axisless_source_name( source: &AxislessSource, ufo_style_name: Option<&str>, used_names: &HashSet, @@ -641,7 +641,7 @@ fn axisless_source_name( ) } -fn parse_axisless_sources(xml: &str) -> DesignspaceResult> { +pub(super) fn parse_axisless_sources(xml: &str) -> DesignspaceResult> { let mut reader = Reader::from_str(xml); reader.config_mut().trim_text(true); let mut sources = Vec::new(); @@ -702,7 +702,7 @@ fn xml_attr( Ok(None) } -fn source_name( +pub(super) fn source_name( source: &norad::designspace::Source, ufo_style_name: Option<&str>, used_names: &HashSet, @@ -763,7 +763,7 @@ fn imported_source_name( } } -fn location_from_dimensions( +pub(super) fn location_from_dimensions( dimensions: &[norad::designspace::Dimension], doc: &DesignSpaceDocument, axes: &[Axis], @@ -847,7 +847,7 @@ fn map_axis_value(axis: &norad::designspace::Axis, user_value: f64) -> f64 { // design-space locations to the mapped user-space defaults. Layer-backed // sources remain eligible because `layer` describes storage, not source role. // https://fonttools.readthedocs.io/en/stable/designspaceLib/python.html#fontTools.designspaceLib.DesignSpaceDocument.findDefault -fn find_default_source_index(doc: &DesignSpaceDocument) -> Option { +pub(super) fn find_default_source_index(doc: &DesignSpaceDocument) -> Option { doc.sources.iter().position(|source| { doc.axes.iter().all(|axis| { let source_value = source_axis_design_value(&source.location, axis); @@ -970,7 +970,7 @@ fn remove_glyph_layers_without_source(font: &mut Font) -> DesignspaceResult<()> /// - **One-sided** (only min OR max specified): the missing side falls back /// to `default`. Common with slant axes (`min=-15, default=0, max=0`). /// - **Degenerate** (no min/max/values): all three collapse to default. -fn derive_axis_range(ds_axis: &norad::designspace::Axis) -> (f64, f64) { +pub(super) fn derive_axis_range(ds_axis: &norad::designspace::Axis) -> (f64, f64) { let values_range = || { ds_axis .values diff --git a/crates/shift-backends/src/font_loader.rs b/crates/shift-backends/src/font_loader.rs index 05106ff8..7f8e8ab8 100644 --- a/crates/shift-backends/src/font_loader.rs +++ b/crates/shift-backends/src/font_loader.rs @@ -8,6 +8,7 @@ use crate::designspace::{DesignspaceReader, DesignspaceWriter}; use crate::errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; use crate::format::FontFormat; use crate::glyphs::GlyphsReader; +use crate::import::FontImport; use crate::traits::{FontReader, FontWriter}; use crate::ufo::{UfoReader, UfoWriter}; @@ -118,6 +119,35 @@ impl FontLoader { self.adaptors.keys().collect() } + pub fn stream_font(&self, path: &str) -> BackendResult { + let path = Path::new(path); + let ext = extension_from_path(path)?; + let format = format_from_extension(ext)?; + let path_buf = path.to_path_buf(); + let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { + path: path_buf.clone(), + })?; + + match format { + FontFormat::Ttf | FontFormat::Otf => { + let (header, stream) = crate::binary::stream_font_file(path) + .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; + Ok(FontImport::new(header, stream, format, path_buf)) + } + FontFormat::Ufo => { + let (header, stream) = crate::ufo::stream_font(path) + .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; + Ok(FontImport::new(header, stream, format, path_buf)) + } + FontFormat::Designspace => { + let (header, stream) = crate::designspace::stream_font(path) + .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; + Ok(FontImport::new(header, stream, format, path_buf)) + } + _ => Err(BackendError::MissingAdaptor { format }), + } + } + pub fn read_font(&self, path: &str) -> BackendResult { let path = Path::new(path); let ext = extension_from_path(path)?; diff --git a/crates/shift-backends/src/import.rs b/crates/shift-backends/src/import.rs new file mode 100644 index 00000000..2e0ed0b1 --- /dev/null +++ b/crates/shift-backends/src/import.rs @@ -0,0 +1,91 @@ +use std::path::PathBuf; + +use shift_font::{Font, Glyph, GlyphId, GlyphName}; + +use crate::{BackendError, BackendResult, FontFormat, FormatBackendResult}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GlyphDirectoryEntry { + pub glyph_id: GlyphId, + pub name: GlyphName, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ImportBatchLimit { + max_glyphs: usize, + max_layers: usize, +} + +impl ImportBatchLimit { + pub fn new(max_glyphs: usize, max_layers: usize) -> Self { + Self { + max_glyphs: max_glyphs.max(1), + max_layers: max_layers.max(1), + } + } + + pub fn max_glyphs(self) -> usize { + self.max_glyphs + } + + pub fn max_layers(self) -> usize { + self.max_layers + } +} + +impl Default for ImportBatchLimit { + fn default() -> Self { + Self::new(512, 1_024) + } +} + +pub(crate) trait GlyphStream: Send { + fn directory(&self) -> Vec; + fn glyph_count(&self) -> usize; + fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult>; +} + +/// A bounded foreign-font import. +/// +/// The header contains top-level authored state but no glyphs. Glyph geometry +/// is materialized only in batches requested by [`Self::next_batch`]. +pub struct FontImport { + header: Font, + stream: Box, + format: FontFormat, + path: PathBuf, +} + +impl FontImport { + pub(crate) fn new( + header: Font, + stream: impl GlyphStream + 'static, + format: FontFormat, + path: PathBuf, + ) -> Self { + Self { + header, + stream: Box::new(stream), + format, + path, + } + } + + pub fn header(&self) -> &Font { + &self.header + } + + pub fn directory(&self) -> Vec { + self.stream.directory() + } + + pub fn glyph_count(&self) -> usize { + self.stream.glyph_count() + } + + pub fn next_batch(&mut self, limit: ImportBatchLimit) -> BackendResult> { + self.stream + .next_batch(limit) + .map_err(|source| BackendError::load(self.format, self.path.clone(), source)) + } +} diff --git a/crates/shift-backends/src/lib.rs b/crates/shift-backends/src/lib.rs index d8283c72..4fd2c06a 100644 --- a/crates/shift-backends/src/lib.rs +++ b/crates/shift-backends/src/lib.rs @@ -6,6 +6,7 @@ pub mod export; pub mod font_loader; pub mod format; pub mod glyphs; +pub mod import; mod metrics; mod shift2fontir; mod traits; @@ -14,4 +15,5 @@ pub mod ufo; pub use errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; pub use export::{ExportError, ExportFormat, FontExportRequest, FontExportResult, FontExporter}; pub use format::FontFormat; +pub use import::{FontImport, GlyphDirectoryEntry, ImportBatchLimit}; pub use traits::{FontBackend, FontReader, FontView, FontWriter}; diff --git a/crates/shift-backends/src/ufo/import.rs b/crates/shift-backends/src/ufo/import.rs new file mode 100644 index 00000000..8a9bb5ad --- /dev/null +++ b/crates/shift-backends/src/ufo/import.rs @@ -0,0 +1,294 @@ +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + path::{Path, PathBuf}, +}; + +use norad::{DataRequest, Font as NoradFont}; +use rayon::prelude::*; +use shift_font::{Font, Glyph, GlyphId, LayerId, Source, SourceId}; + +use crate::{ + errors::{FormatBackendError, FormatBackendResult}, + import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, +}; + +use super::UfoReader; + +struct LayerDirectory { + source_id: SourceId, + glyphs: BTreeMap, +} + +#[derive(Clone)] +struct LayerGlyph { + source_id: SourceId, + layer_id: LayerId, + path: PathBuf, +} + +#[derive(Clone)] +struct GlyphRecord { + id: GlyphId, + name: String, + layers: Vec, +} + +pub(crate) struct UfoGlyphStream { + glyph_ids: HashMap, + glyphs: Vec, + next_glyph: usize, +} + +pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, UfoGlyphStream)> { + let ufo_path = Path::new(path); + let mut header = load_header(ufo_path)?; + let default_source_id = header + .default_source_id() + .expect("UFO header should contain its default source"); + let layers = load_layer_directories(ufo_path, &mut header, default_source_id)?; + let (glyph_ids, glyphs) = build_glyph_directory(layers); + + Ok(( + header, + UfoGlyphStream { + glyph_ids, + glyphs, + next_glyph: 0, + }, + )) +} + +impl GlyphStream for UfoGlyphStream { + fn directory(&self) -> Vec { + self.glyphs + .iter() + .map(|glyph| GlyphDirectoryEntry { + glyph_id: glyph.id.clone(), + name: glyph.name.clone().into(), + }) + .collect() + } + + fn glyph_count(&self) -> usize { + self.glyphs.len() + } + + fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { + if self.next_glyph == self.glyphs.len() { + return Ok(Vec::new()); + } + + let mut end = self.next_glyph; + let mut layer_count = 0; + while end < self.glyphs.len() && end - self.next_glyph < limit.max_glyphs() { + let next_layers = self.glyphs[end].layers.len(); + if end > self.next_glyph && layer_count + next_layers > limit.max_layers() { + break; + } + layer_count += next_layers; + end += 1; + } + let glyph_ids = &self.glyph_ids; + let glyphs = self.glyphs[self.next_glyph..end] + .par_iter() + .map(|record| load_glyph(record, glyph_ids)) + .collect::>>()?; + self.next_glyph = end; + Ok(glyphs) + } +} + +fn load_layer_directories( + ufo_path: &Path, + header: &mut Font, + default_source_id: SourceId, +) -> FormatBackendResult> { + let mut layer_paths = read_layer_paths(ufo_path)?; + let default_index = layer_paths + .iter() + .position(|(_, path)| path == Path::new("glyphs")) + .ok_or_else(|| FormatBackendError::Ufo("UFO is missing its default glyphs layer".into()))?; + layer_paths.rotate_left(default_index); + + let mut layers = Vec::with_capacity(layer_paths.len()); + for (index, (name, relative_path)) in layer_paths.into_iter().enumerate() { + let layer_path = ufo_path.join(&relative_path); + let contents_path = layer_path.join("contents.plist"); + let glyphs: BTreeMap = + plist::from_file(&contents_path).map_err(|error| { + FormatBackendError::Ufo(format!( + "failed to read {}: {error}", + contents_path.display() + )) + })?; + let (color, lib) = read_layer_info(&layer_path)?; + let source_id = if index == 0 { + default_source_id.clone() + } else { + header.add_source(Source::layer(name)) + }; + let source = header + .source_mut(source_id.clone()) + .expect("newly registered UFO source should exist"); + source.set_color(color); + if !lib.is_empty() { + *source.lib_mut() = UfoReader::convert_lib(&lib); + } + layers.push(LayerDirectory { + source_id, + glyphs: glyphs + .into_iter() + .map(|(name, path)| (name, layer_path.join(path))) + .collect(), + }); + } + Ok(layers) +} + +pub(crate) fn load_header(ufo_path: &Path) -> FormatBackendResult { + let request = DataRequest::all().layers(false); + let norad_font = NoradFont::load_requested_data(ufo_path, request) + .map_err(|error| FormatBackendError::Ufo(error.to_string()))?; + UfoReader::convert_header(&norad_font, ufo_path) +} + +pub(crate) fn read_glyph_paths( + ufo_path: &Path, + layer_name: Option<&str>, +) -> FormatBackendResult> { + let layer_paths = read_layer_paths(ufo_path)?; + let relative_path = match layer_name { + Some(layer_name) => layer_paths + .iter() + .find(|(name, _)| name == layer_name) + .map(|(_, path)| path), + None => layer_paths + .iter() + .find(|(_, path)| path == Path::new("glyphs")) + .map(|(_, path)| path), + } + .ok_or_else(|| { + FormatBackendError::Ufo(format!( + "UFO layer {:?} does not exist in {}", + layer_name.unwrap_or("public.default"), + ufo_path.display() + )) + })?; + let layer_path = ufo_path.join(relative_path); + let contents_path = layer_path.join("contents.plist"); + let glyphs: BTreeMap = plist::from_file(&contents_path).map_err(|error| { + FormatBackendError::Ufo(format!( + "failed to read {}: {error}", + contents_path.display() + )) + })?; + Ok(glyphs + .into_iter() + .map(|(name, path)| (name, layer_path.join(path))) + .collect()) +} + +fn read_layer_paths(ufo_path: &Path) -> FormatBackendResult> { + let path = ufo_path.join("layercontents.plist"); + if !path.exists() { + return Ok(vec![("public.default".into(), PathBuf::from("glyphs"))]); + } + plist::from_file(&path).map_err(|error| { + FormatBackendError::Ufo(format!("failed to read {}: {error}", path.display())) + }) +} + +fn read_layer_info(layer_path: &Path) -> FormatBackendResult<(Option, plist::Dictionary)> { + let path = layer_path.join("layerinfo.plist"); + if !path.exists() { + return Ok((None, plist::Dictionary::new())); + } + let value = plist::Value::from_file(&path).map_err(|error| { + FormatBackendError::Ufo(format!("failed to read {}: {error}", path.display())) + })?; + let Some(dictionary) = value.as_dictionary() else { + return Err(FormatBackendError::Ufo(format!( + "{} is not a dictionary", + path.display() + ))); + }; + let color = dictionary + .get("color") + .and_then(plist::Value::as_string) + .map(ToOwned::to_owned); + let lib = dictionary + .get("lib") + .and_then(plist::Value::as_dictionary) + .cloned() + .unwrap_or_default(); + Ok((color, lib)) +} + +fn build_glyph_directory( + layers: Vec, +) -> (HashMap, Vec) { + let mut ordered_names = Vec::new(); + let mut seen = HashSet::new(); + for layer in &layers { + for name in layer.glyphs.keys() { + if seen.insert(name.clone()) { + ordered_names.push(name.clone()); + } + } + } + let glyph_ids = ordered_names + .iter() + .map(|name| (name.clone(), GlyphId::new())) + .collect::>(); + let glyphs = ordered_names + .into_iter() + .map(|name| { + let layer_records = layers + .iter() + .filter_map(|layer| { + layer.glyphs.get(&name).map(|path| LayerGlyph { + source_id: layer.source_id.clone(), + layer_id: LayerId::new(), + path: path.clone(), + }) + }) + .collect(); + GlyphRecord { + id: glyph_ids[&name].clone(), + name, + layers: layer_records, + } + }) + .collect(); + (glyph_ids, glyphs) +} + +fn load_glyph( + record: &GlyphRecord, + glyph_ids: &HashMap, +) -> FormatBackendResult { + let mut glyph = Glyph::with_id(record.id.clone(), record.name.clone()); + for (index, layer_record) in record.layers.iter().enumerate() { + let norad_glyph = norad::Glyph::load(&layer_record.path).map_err(|error| { + FormatBackendError::Ufo(format!( + "failed to read glyph {:?} from {}: {error}", + record.name, + layer_record.path.display() + )) + })?; + if index == 0 { + glyph.set_unicodes(norad_glyph.codepoints.iter().map(u32::from).collect()); + if !norad_glyph.lib.is_empty() { + *glyph.lib_mut() = UfoReader::convert_lib(&norad_glyph.lib); + } + } + let layer = UfoReader::convert_stream_layer( + &norad_glyph, + layer_record.layer_id.clone(), + layer_record.source_id.clone(), + glyph_ids, + )?; + glyph.set_layer(layer); + } + Ok(glyph) +} diff --git a/crates/shift-backends/src/ufo/mod.rs b/crates/shift-backends/src/ufo/mod.rs index df379485..1fc04c55 100644 --- a/crates/shift-backends/src/ufo/mod.rs +++ b/crates/shift-backends/src/ufo/mod.rs @@ -1,6 +1,8 @@ +mod import; mod reader; mod writer; +pub(crate) use import::{load_header, read_glyph_paths, stream_font}; pub use reader::UfoReader; pub use writer::UfoWriter; diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index a98fdbbc..4630759b 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -3,9 +3,9 @@ use crate::metrics::set_metric_position; use crate::traits::FontReader; use norad::{Font as NoradFont, Line}; use shift_font::{ - Anchor, Component, Contour, FeatureData, Font, Glyph, GlyphLayer, Guideline, KerningData, - KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, Source, SourceId, - Transform, + Anchor, Component, Contour, FeatureData, Font, Glyph, GlyphId, GlyphLayer, Guideline, + KerningData, KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, + Source, SourceId, Transform, }; use std::collections::HashMap; use std::path::Path; @@ -65,7 +65,7 @@ impl UfoReader { } } - fn convert_contour(contour: &norad::Contour) -> Contour { + pub(crate) fn convert_contour(contour: &norad::Contour) -> Contour { let mut shift_contour = Contour::new(); let is_closed = contour.is_closed(); @@ -96,7 +96,7 @@ impl UfoReader { } } - fn convert_anchor(anchor: &norad::Anchor) -> Anchor { + pub(crate) fn convert_anchor(anchor: &norad::Anchor) -> Anchor { Anchor::new( anchor.name.as_ref().map(|name| name.to_string()), anchor.x, @@ -104,7 +104,7 @@ impl UfoReader { ) } - fn convert_guideline(guideline: &norad::Guideline) -> Guideline { + pub(crate) fn convert_guideline(guideline: &norad::Guideline) -> Guideline { let mut converted = match guideline.line { Line::Horizontal(y) => Guideline::horizontal(y), Line::Vertical(x) => Guideline::vertical(x), @@ -177,7 +177,7 @@ impl UfoReader { } } - fn convert_lib(lib: &plist::Dictionary) -> LibData { + pub(crate) fn convert_lib(lib: &plist::Dictionary) -> LibData { let mut data = HashMap::new(); for (k, v) in lib.iter() { data.insert(k.clone(), Self::convert_plist_to_lib_value(v)); @@ -230,6 +230,37 @@ impl UfoReader { (glyph, pending_components) } + pub(crate) fn convert_stream_layer( + norad_glyph: &norad::Glyph, + layer_id: LayerId, + source_id: SourceId, + glyph_ids: &HashMap, + ) -> FormatBackendResult { + let (glyph, pending_components) = + Self::convert_glyph_layer(norad_glyph, layer_id, source_id); + let mut layer = glyph + .layers() + .values() + .next() + .expect("converted glyph should contain its layer") + .as_ref() + .clone(); + for pending in pending_components { + let base_glyph_id = glyph_ids.get(&pending.base_glyph_name).ok_or_else(|| { + FormatBackendError::Ufo(format!( + "component base glyph {:?} does not exist", + pending.base_glyph_name + )) + })?; + layer.add_component(Component::with_matrix( + base_glyph_id.clone(), + pending.base_glyph_name, + &pending.matrix, + )); + } + Ok(layer) + } + fn convert_kerning(norad_font: &NoradFont) -> KerningData { let mut kerning = KerningData::new(); @@ -283,48 +314,10 @@ impl UfoReader { } } - fn resolve_components( - font: &mut Font, - pending_components: Vec, - ) -> FormatBackendResult<()> { - for pending in pending_components { - let base_glyph_id = - font.glyph_id_by_name(&pending.base_glyph_name) - .ok_or_else(|| { - FormatBackendError::Ufo(format!( - "component base glyph {:?} does not exist", - pending.base_glyph_name - )) - })?; - let layer = font.layer_mut(pending.layer_id.clone()).ok_or_else(|| { - FormatBackendError::Ufo(format!( - "component target layer {} does not exist", - pending.layer_id - )) - })?; - layer.add_component(Component::with_matrix( - base_glyph_id, - pending.base_glyph_name, - &pending.matrix, - )); - } - - Ok(()) - } -} - -impl Default for UfoReader { - fn default() -> Self { - Self::new() - } -} - -impl FontReader for UfoReader { - fn load(&self, path: &str) -> FormatBackendResult { - let norad_font = - NoradFont::load(path).map_err(|e| FormatBackendError::Ufo(e.to_string()))?; - let ufo_path = Path::new(path); - + pub(crate) fn convert_header( + norad_font: &NoradFont, + ufo_path: &Path, + ) -> FormatBackendResult { let mut font = Font::new(); let default_source_id = font .default_source_id() @@ -350,15 +343,14 @@ impl FontReader for UfoReader { font.metadata_mut().description = norad_font.font_info.open_type_name_description.clone(); font.metadata_mut().note = norad_font.font_info.note.clone(); - let upm = norad_font + font.metrics_mut().units_per_em = norad_font .font_info .units_per_em - .map(|n| *n) + .map(|value| *value) .unwrap_or(1000.0); - font.metrics_mut().units_per_em = upm; let metric_definitions = font.metric_definitions().to_vec(); let default_source = font - .source_mut(default_source_id.clone()) + .source_mut(default_source_id) .expect("new font should contain its default source"); set_metric_position( &metric_definitions, @@ -398,6 +390,79 @@ impl FontReader for UfoReader { if let Some(remainder) = Self::convert_fontinfo_remainder(&norad_font.font_info)? { *font.fontinfo_remainder_mut() = remainder; } + *font.kerning_mut() = Self::convert_kerning(norad_font); + *font.features_mut() = Self::load_features(ufo_path); + for guideline in norad_font.guidelines() { + font.add_guideline(Self::convert_guideline(guideline)); + } + if !norad_font.lib.is_empty() { + *font.lib_mut() = Self::convert_lib(&norad_font.lib); + } + for (data_path, contents) in norad_font.data.iter() { + let bytes = contents.map_err(|error| { + FormatBackendError::Ufo(format!("failed to read data file {data_path:?}: {error}")) + })?; + font.data_files_mut() + .insert(data_path.to_string_lossy().into_owned(), bytes.to_vec()); + } + for (image_path, contents) in norad_font.images.iter() { + let bytes = contents.map_err(|error| { + FormatBackendError::Ufo(format!( + "failed to read image file {image_path:?}: {error}" + )) + })?; + font.images_mut() + .insert(image_path.to_string_lossy().into_owned(), bytes.to_vec()); + } + Ok(font) + } + + fn resolve_components( + font: &mut Font, + pending_components: Vec, + ) -> FormatBackendResult<()> { + for pending in pending_components { + let base_glyph_id = + font.glyph_id_by_name(&pending.base_glyph_name) + .ok_or_else(|| { + FormatBackendError::Ufo(format!( + "component base glyph {:?} does not exist", + pending.base_glyph_name + )) + })?; + let layer = font.layer_mut(pending.layer_id.clone()).ok_or_else(|| { + FormatBackendError::Ufo(format!( + "component target layer {} does not exist", + pending.layer_id + )) + })?; + layer.add_component(Component::with_matrix( + base_glyph_id, + pending.base_glyph_name, + &pending.matrix, + )); + } + + Ok(()) + } +} + +impl Default for UfoReader { + fn default() -> Self { + Self::new() + } +} + +impl FontReader for UfoReader { + fn load(&self, path: &str) -> FormatBackendResult { + let norad_font = + NoradFont::load(path).map_err(|e| FormatBackendError::Ufo(e.to_string()))?; + let ufo_path = Path::new(path); + + let mut font = Self::convert_header(&norad_font, ufo_path)?; + let default_source_id = font + .default_source_id() + .expect("new font should have a default source"); let norad_default_layer_name = norad_font.layers.default_layer().name().clone(); let mut pending_components = Vec::new(); @@ -433,34 +498,6 @@ impl FontReader for UfoReader { } } Self::resolve_components(&mut font, pending_components)?; - - *font.kerning_mut() = Self::convert_kerning(&norad_font); - *font.features_mut() = Self::load_features(ufo_path); - - for guideline in norad_font.guidelines() { - font.add_guideline(Self::convert_guideline(guideline)); - } - - if !norad_font.lib.is_empty() { - *font.lib_mut() = Self::convert_lib(&norad_font.lib); - } - - for (data_path, contents) in norad_font.data.iter() { - let bytes = contents.map_err(|e| { - FormatBackendError::Ufo(format!("failed to read data file {data_path:?}: {e}")) - })?; - font.data_files_mut() - .insert(data_path.to_string_lossy().into_owned(), bytes.to_vec()); - } - - for (image_path, contents) in norad_font.images.iter() { - let bytes = contents.map_err(|e| { - FormatBackendError::Ufo(format!("failed to read image file {image_path:?}: {e}")) - })?; - font.images_mut() - .insert(image_path.to_string_lossy().into_owned(), bytes.to_vec()); - } - Ok(font) } } diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index 52db4acc..41845cf4 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -52,6 +52,36 @@ fn load_font(path: &Path) -> Font { .unwrap_or_else(|error| panic!("failed to load {}: {error}", path.display())) } +fn stream_font(path: &Path) -> Font { + let mut import = FontLoader::new() + .stream_font(path.to_str().unwrap()) + .unwrap_or_else(|error| panic!("failed to stream {}: {error}", path.display())); + assert_eq!(import.header().glyph_count(), 0); + let directory = import.directory(); + let expected_count = import.glyph_count(); + assert_eq!(directory.len(), expected_count); + let mut font = import.header().clone(); + loop { + let glyphs = import + .next_batch(shift_backends::ImportBatchLimit::new(8, 64)) + .unwrap(); + if glyphs.is_empty() { + break; + } + for glyph in glyphs { + font.insert_glyph(glyph).unwrap(); + } + } + assert_eq!(font.glyph_count(), expected_count); + for entry in directory { + assert_eq!( + font.glyph(entry.glyph_id).map(Glyph::glyph_name), + Some(&entry.name) + ); + } + font +} + fn main_layer(glyph: &Glyph) -> &GlyphLayer { glyph .layers() @@ -176,6 +206,65 @@ fn loads_binary_fonts_with_contours() { } } +#[test] +fn streams_binary_ufo_and_designspace_without_eager_glyphs() { + let binary_path = mutatorsans_ttf_path(); + let binary_bytes = std::fs::read(&binary_path).unwrap(); + let binary = skrifa::FontRef::new(&binary_bytes).unwrap(); + let expected_binary_glyphs = skrifa::raw::TableProvider::maxp(&binary) + .unwrap() + .num_glyphs() as usize; + let streamed_binary = stream_font(&binary_path); + assert_eq!(streamed_binary.glyph_count(), expected_binary_glyphs); + + for path in [binary_path, mutatorsans_ufo_path()] { + let eager = load_font(&path); + let streamed = stream_font(&path); + assert!(streamed.glyph_count() >= eager.glyph_count()); + let glyph_a = streamed + .glyphs_by_unicode(65) + .next() + .unwrap_or_else(|| panic!("{} should stream U+0041", path.display())); + assert!(!main_layer(glyph_a).contours().is_empty()); + } + + let path = mutatorsans_designspace_path(); + let eager = load_font(&path); + let streamed = stream_font(&path); + assert_eq!(streamed.glyph_count(), eager.glyph_count()); + assert_eq!(streamed.axes().len(), eager.axes().len()); + assert_eq!(streamed.sources().len(), eager.sources().len()); + assert_eq!( + streamed + .glyphs() + .map(Glyph::name) + .collect::>(), + eager + .glyphs() + .map(Glyph::name) + .collect::>() + ); +} + +#[test] +fn streaming_batches_bound_authored_layers_across_sources() { + let path = mutatorsans_designspace_path(); + let mut import = FontLoader::new() + .stream_font(path.to_str().unwrap()) + .unwrap(); + let glyphs = import + .next_batch(shift_backends::ImportBatchLimit::new(512, 4)) + .unwrap(); + let layer_count = glyphs + .iter() + .map(|glyph| glyph.layers().len()) + .sum::(); + + assert!(!glyphs.is_empty()); + assert!(glyphs.len() < import.glyph_count()); + assert!(layer_count <= 4 || glyphs.len() == 1); +} + #[test] fn loads_binary_variable_axes_and_named_instances() { let font = load_font(&host_grotesk_variable_ttf_path()); diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index a24e0b91..f30b40f2 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -70,7 +70,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. - Own font authoring data structures such as `Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, `Source`, and `Axis`. - Keep object-level mutation behavior near the objects it mutates. -- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. +- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` atomically hydrates or evicts a bounded layer batch with one copy-on-write index rebuild. - Own canonical glyph and source-metric interpolation value ordering, variation-model construction, interpolation evaluation, and location-bound glyph resolution. - Stay independent of TypeScript, NAPI, and bridge DTOs. diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index 72e82ab8..06203aa6 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -1110,35 +1110,57 @@ impl Font { /// storage acquisition to hydrate a directory placeholder without /// changing glyph/layer ownership. pub fn replace_glyph_layer(&mut self, layer: GlyphLayer) -> CoreResult<()> { - let layer_id = layer.id(); - let glyph_id = self - .glyph_id_by_layer(layer_id.clone()) - .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; - let previous = self - .layer(layer_id.clone()) - .ok_or(CoreError::LayerNotFound(layer_id.clone()))? - .clone(); - if layer.source_id() != previous.source_id() { - return Err(CoreError::LayerSourceMismatch { - layer_id, - expected_source_id: previous.source_id(), - actual_source_id: layer.source_id(), - }); + self.replace_glyph_layers(vec![layer]) + } + + /// Atomically replaces existing layers while cloning and validating the + /// font index only once for the complete batch. + pub fn replace_glyph_layers(&mut self, layers: Vec) -> CoreResult<()> { + if layers.is_empty() { + return Ok(()); } - let mut next_index = self.index().clone(); - next_index.remove_layer(glyph_id.clone(), &previous); - next_index.validate_layer_insert(&glyph_id, &layer)?; - next_index.insert_layer(glyph_id.clone(), &layer); + let mut seen_layer_ids = HashSet::with_capacity(layers.len()); + let mut replacements = Vec::with_capacity(layers.len()); + for layer in layers { + let layer_id = layer.id(); + if !seen_layer_ids.insert(layer_id.clone()) { + return Err(CoreError::DuplicateLayerId(layer_id)); + } - let state = self.state_mut(); - let glyph = state - .data - .glyphs - .get_mut(&glyph_id) - .ok_or(CoreError::GlyphNotFound(glyph_id))?; - Arc::make_mut(glyph).set_layer(layer); - state.index = next_index; + let glyph_id = self + .glyph_id_by_layer(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; + let previous = self + .layer(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id.clone()))? + .clone(); + if layer.source_id() != previous.source_id() { + return Err(CoreError::LayerSourceMismatch { + layer_id, + expected_source_id: previous.source_id(), + actual_source_id: layer.source_id(), + }); + } + replacements.push((glyph_id, previous, layer)); + } + + let mut next_state = self.state.as_ref().clone(); + for (glyph_id, previous, _) in &replacements { + next_state.index.remove_layer(glyph_id.clone(), previous); + } + for (glyph_id, _, layer) in replacements { + next_state.index.validate_layer_insert(&glyph_id, &layer)?; + next_state.index.insert_layer(glyph_id.clone(), &layer); + let glyph = next_state + .data + .glyphs + .get_mut(&glyph_id) + .ok_or(CoreError::GlyphNotFound(glyph_id))?; + Arc::make_mut(glyph).set_layer(layer); + } + + self.state = Arc::new(next_state); Ok(()) } @@ -1699,6 +1721,51 @@ mod tests { ); } + #[test] + fn glyph_layer_batch_replacement_is_atomic() { + let mut font = Font::new(); + let source_id = font.default_source_id().unwrap(); + let first_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 500.0); + let first_layer_id = first_layer.id(); + let mut first_glyph = Glyph::new("A"); + first_glyph.set_layer(first_layer); + font.insert_glyph(first_glyph).unwrap(); + + let second_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 600.0); + let second_layer_id = second_layer.id(); + let mut second_glyph = Glyph::new("B"); + second_glyph.set_layer(second_layer); + font.insert_glyph(second_glyph).unwrap(); + + let contour_id = ContourId::new(); + let mut first_contour = Contour::with_id(contour_id.clone()); + first_contour.add_point(0.0, 0.0, PointType::OnCurve, false); + let mut first_replacement = + GlyphLayer::with_width(first_layer_id.clone(), source_id.clone(), 700.0); + first_replacement.add_contour(first_contour); + + let mut second_contour = Contour::with_id(contour_id.clone()); + second_contour.add_point(10.0, 10.0, PointType::OnCurve, false); + let mut second_replacement = + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0); + second_replacement.add_contour(second_contour); + + assert!(matches!( + font.replace_glyph_layers(vec![first_replacement, second_replacement]), + Err(CoreError::DuplicateContourId(id)) if id == contour_id + )); + assert_eq!(font.layer(first_layer_id.clone()).unwrap().width(), 500.0); + assert_eq!(font.layer(second_layer_id.clone()).unwrap().width(), 600.0); + + font.replace_glyph_layers(vec![ + GlyphLayer::with_width(first_layer_id.clone(), source_id.clone(), 700.0), + GlyphLayer::with_width(second_layer_id.clone(), source_id, 800.0), + ]) + .unwrap(); + assert_eq!(font.layer(first_layer_id).unwrap().width(), 700.0); + assert_eq!(font.layer(second_layer_id).unwrap().width(), 800.0); + } + #[test] fn font_remove_insert_glyph() { let mut font = Font::new(); diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml index 0fcc7ffe..f53d663c 100644 --- a/crates/shift-store/Cargo.toml +++ b/crates/shift-store/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" shift-font = { workspace = true } shift-glyph-codec = { workspace = true } rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } +rayon = "1.10" serde_json = "1.0" thiserror = "2" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index c57407f5..db7ca86c 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -12,11 +12,13 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload and cross-checks its directory and reference-index facts. `FontWorkspace` uses those APIs for explicit glyph acquisition and safe cache eviction. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload; `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB of packed bytes. Both paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. ## Import boundary -`LayerStreamWriter` accepts one parsed `GlyphLayer` at a time. A successful call encodes, indexes, commits, and releases that layer before the caller supplies the next one; no complete in-memory `Font` is required. Format-specific bounded parsers, progress/cancellation, and atomic staging installation belong to the foreign-import layer. +`LayerStreamWriter` accepts one glyph/layer or a bounded glyph batch without requiring a complete in-memory `Font`. `write_glyph_batch` canonical-encodes layers with Rayon, then writes directory rows, BLOBs, and component indexes in stable order through one SQLite transaction. `finish` is the only commit point; dropping an unfinished writer rolls the complete stream back. + +`ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the destination is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL before workspace state is published. Normal edits never use import pragmas. Progress, cancellation, source fingerprinting, and final atomic destination installation remain workspace responsibilities. ## Schema policy @@ -24,7 +26,7 @@ Shift has not shipped this working-store schema. Schema changes therefore update ## Responsibilities -- configure SQLite for WAL-backed transactional work; +- configure SQLite for WAL-backed transactional work and a separately finalized disposable-import mode; - own the baseline schema and raw SQL; - preserve stable authored IDs and exact values; - provide directory-first and bounded payload APIs; @@ -35,11 +37,11 @@ Shift has not shipped this working-store schema. Schema changes therefore update ```text src/ - connection.rs # SQLite opening, WAL, limits, foreign keys + connection.rs # normal WAL opening plus disposable import/finalization posture schema.rs # pre-release version-1 baseline font_state.rs # eager metadata/directory and explicit full materialization packed_layer.rs # bounded BLOB fetch/replace and reference-index checks - stream_writer.rs # one-layer-at-a-time import sink + stream_writer.rs # bounded Rayon pack plus single-transaction SQLite sink change_set.rs # transactional workspace changes workspace_state.rs ``` diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index 1c36843a..e5242135 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -55,105 +55,158 @@ impl ShiftStore { pub fn replace_font_state(&mut self, font: &font::Font) -> Result<(), StoreError> { let tx = self.conn.transaction()?; + replace_font_header_in_tx(&tx, font)?; - tx.execute("DELETE FROM glyph_lib", [])?; - tx.execute("DELETE FROM font_lib", [])?; - tx.execute("DELETE FROM fontinfo_remainder", [])?; - tx.execute("DELETE FROM font_binaries", [])?; - tx.execute("DELETE FROM kerning_pairs", [])?; - tx.execute("DELETE FROM kerning_group_members", [])?; - tx.execute("DELETE FROM kerning_groups", [])?; - tx.execute("DELETE FROM feature_text", [])?; - tx.execute("DELETE FROM font_guidelines", [])?; - tx.execute("DELETE FROM glyph_components", [])?; - tx.execute("DELETE FROM glyph_layer_payloads", [])?; - tx.execute("DELETE FROM glyph_layers", [])?; - tx.execute("DELETE FROM glyph_unicodes", [])?; - tx.execute("DELETE FROM glyphs", [])?; - tx.execute("DELETE FROM source_locations", [])?; - tx.execute("DELETE FROM source_metric_values", [])?; - tx.execute("DELETE FROM source_lib", [])?; - tx.execute("DELETE FROM sources", [])?; - tx.execute("DELETE FROM metric_definitions", [])?; - tx.execute("DELETE FROM axis_mappings", [])?; - tx.execute("DELETE FROM named_instances", [])?; - tx.execute("DELETE FROM axes", [])?; + for (order_index, glyph) in font.glyphs().enumerate() { + write_glyph_in_tx(&tx, glyph, order_index as i64)?; + } - upsert_font_info(&tx, font)?; - replace_feature_text(&tx, font.features().fea_source())?; - replace_font_guidelines(&tx, font.guidelines())?; - replace_lib_data(&tx, "font_lib", "key", None, font.lib())?; - replace_lib_data( - &tx, - "fontinfo_remainder", - "key", - None, - font.fontinfo_remainder(), - )?; - replace_font_binaries(&tx, "data", font.data_files())?; - replace_font_binaries(&tx, "image", font.images())?; - replace_kerning(&tx, font.kerning())?; + tx.commit()?; + Ok(()) + } +} - for (order_index, axis) in font.axes().iter().enumerate() { - insert_axis(&tx, axis, order_index as i64, false)?; - } - replace_axis_mappings(&tx, font.axis_mappings())?; - replace_named_instances(&tx, font.named_instances())?; - replace_metric_definitions(&tx, font.metric_definitions())?; +pub(crate) fn replace_font_header_in_tx( + tx: &Transaction<'_>, + font: &font::Font, +) -> Result<(), StoreError> { + tx.execute("DELETE FROM glyph_lib", [])?; + tx.execute("DELETE FROM font_lib", [])?; + tx.execute("DELETE FROM fontinfo_remainder", [])?; + tx.execute("DELETE FROM font_binaries", [])?; + tx.execute("DELETE FROM kerning_pairs", [])?; + tx.execute("DELETE FROM kerning_group_members", [])?; + tx.execute("DELETE FROM kerning_groups", [])?; + tx.execute("DELETE FROM feature_text", [])?; + tx.execute("DELETE FROM font_guidelines", [])?; + tx.execute("DELETE FROM glyph_components", [])?; + tx.execute("DELETE FROM glyph_layer_payloads", [])?; + tx.execute("DELETE FROM glyph_layers", [])?; + tx.execute("DELETE FROM glyph_unicodes", [])?; + tx.execute("DELETE FROM glyphs", [])?; + tx.execute("DELETE FROM source_locations", [])?; + tx.execute("DELETE FROM source_metric_values", [])?; + tx.execute("DELETE FROM source_lib", [])?; + tx.execute("DELETE FROM sources", [])?; + tx.execute("DELETE FROM metric_definitions", [])?; + tx.execute("DELETE FROM axis_mappings", [])?; + tx.execute("DELETE FROM named_instances", [])?; + tx.execute("DELETE FROM axes", [])?; - for (order_index, source) in font.sources().iter().enumerate() { - upsert_source( - &tx, - &source.id(), - SourceRow { - name: Some(source.name()), - filename: source.filename(), - color: source.color(), - kind: SourceKind::from(source.role()), - layer_name: source.layer_name(), - italic_angle: source.italic_angle(), - line_gap: source.line_gap(), - underline_position: source.underline_position(), - underline_thickness: source.underline_thickness(), - order_index: order_index as i64, - }, - )?; - replace_source_metric_values(&tx, source.id(), source.metric_values().iter())?; - replace_lib_data( - &tx, - "source_lib", - "source_id", - Some(&source.id().to_string()), - source.lib(), - )?; + upsert_font_info(tx, font)?; + replace_feature_text(tx, font.features().fea_source())?; + replace_font_guidelines(tx, font.guidelines())?; + replace_lib_data(tx, "font_lib", "key", None, font.lib())?; + replace_lib_data( + tx, + "fontinfo_remainder", + "key", + None, + font.fontinfo_remainder(), + )?; + replace_font_binaries(tx, "data", font.data_files())?; + replace_font_binaries(tx, "image", font.images())?; + replace_kerning(tx, font.kerning())?; - for (axis_id, value) in source.location().iter() { - // Location entries on undefined axes have no row to reference. - if font.axes().iter().any(|axis| axis.id() == *axis_id) { - upsert_source_location(&tx, &source.id(), axis_id, *value)?; - } - } - } + for (order_index, axis) in font.axes().iter().enumerate() { + insert_axis(tx, axis, order_index as i64, false)?; + } + replace_axis_mappings(tx, font.axis_mappings())?; + replace_named_instances(tx, font.named_instances())?; + replace_metric_definitions(tx, font.metric_definitions())?; - for (order_index, glyph) in font.glyphs().enumerate() { - upsert_glyph(&tx, &glyph.id(), glyph.glyph_name(), order_index as i64)?; - replace_glyph_unicodes(&tx, &glyph.id(), glyph.unicodes())?; - replace_lib_data( - &tx, - "glyph_lib", - "glyph_id", - Some(&glyph.id().to_string()), - glyph.lib(), - )?; + for (order_index, source) in font.sources().iter().enumerate() { + upsert_source( + tx, + &source.id(), + SourceRow { + name: Some(source.name()), + filename: source.filename(), + color: source.color(), + kind: SourceKind::from(source.role()), + layer_name: source.layer_name(), + italic_angle: source.italic_angle(), + line_gap: source.line_gap(), + underline_position: source.underline_position(), + underline_thickness: source.underline_thickness(), + order_index: order_index as i64, + }, + )?; + replace_source_metric_values(tx, source.id(), source.metric_values().iter())?; + replace_lib_data( + tx, + "source_lib", + "source_id", + Some(&source.id().to_string()), + source.lib(), + )?; - for layer in glyph.layers().values().map(|layer| layer.as_ref()) { - write_layer_in_tx(&tx, &glyph.id(), Some(glyph.glyph_name()), layer)?; + for (axis_id, value) in source.location().iter() { + // Location entries on undefined axes have no row to reference. + if font.axes().iter().any(|axis| axis.id() == *axis_id) { + upsert_source_location(tx, &source.id(), axis_id, *value)?; } } + } - tx.commit()?; - Ok(()) + Ok(()) +} + +pub(crate) fn write_glyph_in_tx( + tx: &Transaction<'_>, + glyph: &font::Glyph, + order_index: i64, +) -> Result<(), StoreError> { + write_glyph_directory_in_tx(tx, glyph, order_index)?; + + for layer in glyph.layers().values().map(|layer| layer.as_ref()) { + write_layer_in_tx(tx, &glyph.id(), Some(glyph.glyph_name()), layer)?; } + Ok(()) +} + +pub(crate) fn insert_glyph_directory_in_tx( + tx: &Transaction<'_>, + glyph: &font::Glyph, + order_index: i64, +) -> Result<(), StoreError> { + tx.prepare_cached("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)")? + .execute(params![ + glyph.id().to_string(), + glyph.glyph_name().as_str(), + order_index + ])?; + for (unicode_order, unicode) in glyph.unicodes().iter().enumerate() { + tx.prepare_cached( + "INSERT INTO glyph_unicodes (glyph_id, unicode, order_index) VALUES (?1, ?2, ?3)", + )? + .execute(params![ + glyph.id().to_string(), + i64::from(*unicode), + unicode_order as i64 + ])?; + } + for (key, value) in glyph.lib().iter() { + tx.prepare_cached("INSERT INTO glyph_lib (glyph_id, key, value_json) VALUES (?1, ?2, ?3)")? + .execute(params![glyph.id().to_string(), key, lib_value_json(value)?])?; + } + Ok(()) +} + +pub(crate) fn write_glyph_directory_in_tx( + tx: &Transaction<'_>, + glyph: &font::Glyph, + order_index: i64, +) -> Result<(), StoreError> { + upsert_glyph(tx, &glyph.id(), glyph.glyph_name(), order_index)?; + replace_glyph_unicodes(tx, &glyph.id(), glyph.unicodes())?; + replace_lib_data( + tx, + "glyph_lib", + "glyph_id", + Some(&glyph.id().to_string()), + glyph.lib(), + ) } fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), StoreError> { @@ -675,7 +728,7 @@ fn upsert_glyph( name: &font::GlyphName, order_index: i64, ) -> Result<(), StoreError> { - tx.execute( + tx.prepare_cached( " INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3) @@ -683,8 +736,8 @@ fn upsert_glyph( name = excluded.name, order_index = excluded.order_index ", - params![glyph_id.to_string(), name.as_str(), order_index], - )?; + )? + .execute(params![glyph_id.to_string(), name.as_str(), order_index])?; Ok(()) } @@ -693,19 +746,21 @@ fn replace_glyph_unicodes( glyph_id: &font::GlyphId, unicodes: &[u32], ) -> Result<(), StoreError> { - tx.execute( - "DELETE FROM glyph_unicodes WHERE glyph_id = ?1", - [glyph_id.to_string()], - )?; + tx.prepare_cached("DELETE FROM glyph_unicodes WHERE glyph_id = ?1")? + .execute([glyph_id.to_string()])?; for (order_index, unicode) in unicodes.iter().enumerate() { - tx.execute( + tx.prepare_cached( " INSERT INTO glyph_unicodes (glyph_id, unicode, order_index) VALUES (?1, ?2, ?3) ", - params![glyph_id.to_string(), *unicode as i64, order_index as i64], - )?; + )? + .execute(params![ + glyph_id.to_string(), + *unicode as i64, + order_index as i64 + ])?; } Ok(()) @@ -946,12 +1001,16 @@ fn replace_lib_data( match owner_id { Some(owner_id) => { let delete_sql = format!("DELETE FROM {table} WHERE {owner_column} = ?1"); - tx.execute(&delete_sql, [owner_id])?; + tx.prepare_cached(&delete_sql)?.execute([owner_id])?; let insert_sql = format!( "INSERT INTO {table} ({owner_column}, key, value_json) VALUES (?1, ?2, ?3)" ); for (key, value) in lib.iter() { - tx.execute(&insert_sql, params![owner_id, key, lib_value_json(value)?])?; + tx.prepare_cached(&insert_sql)?.execute(params![ + owner_id, + key, + lib_value_json(value)? + ])?; } } None => { diff --git a/crates/shift-store/src/connection.rs b/crates/shift-store/src/connection.rs index 56ed20f7..b9a6eb48 100644 --- a/crates/shift-store/src/connection.rs +++ b/crates/shift-store/src/connection.rs @@ -4,23 +4,73 @@ use crate::{ShiftStore, StoreError, schema}; impl ShiftStore { pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); let conn = rusqlite::Connection::open(path)?; configure_connection(&conn)?; schema::ensure_current(&conn)?; - Ok(Self { conn }) + Ok(Self { + conn, + path: Some(path.to_path_buf()), + }) + } + + /// Opens a disposable import destination with rollback-capable in-memory + /// journaling. The foreign source remains authoritative until + /// [`Self::finish_import`] makes the completed store durable. + pub fn open_for_import(path: impl AsRef) -> Result { + let path = path.as_ref(); + let conn = rusqlite::Connection::open(path)?; + configure_import_connection(&conn)?; + schema::ensure_current(&conn)?; + let has_workspace: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM workspace_state WHERE id = 1)", + [], + |row| row.get(0), + )?; + if has_workspace { + return Err(StoreError::ImportDestinationNotEmpty(path.to_path_buf())); + } + Ok(Self { + conn, + path: Some(path.to_path_buf()), + }) + } + + /// Flushes a completed base import and restores the normal edit-time WAL + /// posture before the workspace becomes authoritative. + pub fn finish_import(&self) -> Result<(), StoreError> { + self.sync_store_file()?; + + let journal_mode: String = self + .conn + .query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?; + debug_assert_eq!(journal_mode, "wal"); + self.conn.pragma_update(None, "synchronous", "NORMAL")?; + self.sync_store_file()?; + Ok(()) + } + + fn sync_store_file(&self) -> Result<(), StoreError> { + if let Some(path) = &self.path { + std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path)? + .sync_all()?; + } + Ok(()) } pub fn open_memory_for_test() -> Result { let conn = rusqlite::Connection::open_in_memory()?; configure_connection(&conn)?; schema::ensure_current(&conn)?; - Ok(Self { conn }) + Ok(Self { conn, path: None }) } } fn configure_connection(conn: &rusqlite::Connection) -> Result<(), StoreError> { - conn.pragma_update(None, "foreign_keys", "ON")?; - conn.busy_timeout(std::time::Duration::from_millis(5_000))?; + configure_common(conn)?; // WAL + NORMAL is the durability/latency posture for a store that // commits per user action. In-memory test connections report "memory" @@ -29,6 +79,21 @@ fn configure_connection(conn: &rusqlite::Connection) -> Result<(), StoreError> { let journal_mode: String = conn.query_row("PRAGMA journal_mode=WAL", [], |row| row.get(0))?; debug_assert!(matches!(journal_mode.as_str(), "wal" | "memory")); conn.pragma_update(None, "synchronous", "NORMAL")?; + Ok(()) +} +fn configure_import_connection(conn: &rusqlite::Connection) -> Result<(), StoreError> { + configure_common(conn)?; + let journal_mode: String = + conn.query_row("PRAGMA journal_mode=MEMORY", [], |row| row.get(0))?; + debug_assert_eq!(journal_mode, "memory"); + conn.pragma_update(None, "synchronous", "OFF")?; + Ok(()) +} + +fn configure_common(conn: &rusqlite::Connection) -> Result<(), StoreError> { + conn.set_prepared_statement_cache_capacity(64); + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.busy_timeout(std::time::Duration::from_millis(5_000))?; Ok(()) } diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 4f9a37cf..80e5eb09 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -1,5 +1,8 @@ #[derive(Debug, thiserror::Error)] pub enum StoreError { + #[error("store file-system error: {0}")] + Io(#[from] std::io::Error), + #[error("sqlite error: {0}")] Sqlite(#[from] rusqlite::Error), @@ -18,6 +21,12 @@ pub enum StoreError { #[error("glyph layer payload has {bytes} bytes; limit is {limit}")] LayerPayloadTooLarge { bytes: u64, limit: u64 }, + #[error("glyph layer read batch has {layers} layers; limit is {limit}")] + LayerReadBatchTooLarge { layers: usize, limit: usize }, + + #[error("glyph layer read batch has {bytes} payload bytes; limit is {limit}")] + LayerReadBatchPayloadTooLarge { bytes: u64, limit: u64 }, + #[error("glyph layer {layer_id} payload disagrees with its directory: {detail}")] LayerDirectoryMismatch { layer_id: String, detail: String }, @@ -36,6 +45,9 @@ pub enum StoreError { #[error("invalid workspace state: {0}")] InvalidWorkspaceState(String), + #[error("import destination already contains published workspace state: {0}")] + ImportDestinationNotEmpty(std::path::PathBuf), + #[error("missing {kind}: {id}")] MissingEntity { kind: &'static str, id: String }, diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 44c1d138..4631cadd 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -21,7 +21,10 @@ pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; pub use layer::{GlyphLayerRecord, NewGlyphLayer}; pub use outline::{AnchorRecord, ContourRecord, PointRecord}; -pub use packed_layer::{GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry}; +pub use packed_layer::{ + GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT, + MAX_LAYER_READ_BATCH_PAYLOAD_BYTES, +}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; pub use stream_writer::LayerStreamWriter; diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index 35946426..a007f17f 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -1,4 +1,4 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{HashMap, HashSet, VecDeque}; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use shift_font as font; @@ -6,6 +6,8 @@ use shift_font as font; use crate::{ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx}; pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; +pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; +pub const MAX_LAYER_READ_BATCH_PAYLOAD_BYTES: u64 = 256 * 1024 * 1024; const MAX_LAYER_PAYLOAD_BYTES: i64 = shift_glyph_codec::MAX_LAYER_PAYLOAD_BYTES as i64; /// Relational facts needed to locate a layer without reading its payload. @@ -29,6 +31,15 @@ impl ShiftStore { load_glyph_layer_from_conn(&self.conn, layer_id) } + /// Fetches and cross-validates a bounded layer batch with three ordered + /// SQLite scans rather than three query executions per layer. + pub fn load_glyph_layers( + &self, + layer_ids: &[font::LayerId], + ) -> Result, StoreError> { + load_glyph_layers_from_conn(&self.conn, layer_ids) + } + /// Replaces one independently-addressed layer and its earned reference /// rows in one transaction. The workspace revision advances only after /// encoding and index derivation have succeeded. @@ -156,29 +167,29 @@ pub(crate) fn load_glyph_layer_from_conn( conn: &Connection, layer_id: &font::LayerId, ) -> Result, StoreError> { - let row = conn - .query_row( - " - SELECT l.glyph_id, l.source_id, l.width, l.height, - p.format, p.byte_length, length(p.payload) - FROM glyph_layers AS l - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - WHERE l.id = ?1 - ", - [layer_id.to_string()], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, f64>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, i64>(5)?, - row.get::<_, i64>(6)?, - )) - }, - ) + let mut directory_stmt = conn.prepare_cached( + " + SELECT l.glyph_id, l.source_id, l.width, l.height, + p.format, p.byte_length, length(p.payload) + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id = ?1 + ", + )?; + let row = directory_stmt + .query_row([layer_id.to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + }) .optional()?; + drop(directory_stmt); let Some((_glyph_id, source_id, width, height, format, declared_len, actual_len)) = row else { return Ok(None); }; @@ -201,15 +212,17 @@ pub(crate) fn load_glyph_layer_from_conn( // The length gate is deliberately a separate query so SQLite cannot // allocate an unbounded Vec before the store applies its limit. - let payload: Vec = conn + let mut payload_stmt = conn.prepare_cached( + " + SELECT payload + FROM glyph_layer_payloads + WHERE layer_id = ?1 + AND length(payload) = ?2 + AND length(payload) <= ?3 + ", + )?; + let payload: Vec = payload_stmt .query_row( - " - SELECT payload - FROM glyph_layer_payloads - WHERE layer_id = ?1 - AND length(payload) = ?2 - AND length(payload) <= ?3 - ", params![layer_id.to_string(), actual_len, MAX_LAYER_PAYLOAD_BYTES], |row| row.get(0), ) @@ -224,6 +237,175 @@ pub(crate) fn load_glyph_layer_from_conn( Ok(Some(layer)) } +fn load_glyph_layers_from_conn( + conn: &Connection, + layer_ids: &[font::LayerId], +) -> Result, StoreError> { + let mut keys = layer_ids + .iter() + .map(ToString::to_string) + .collect::>(); + keys.sort(); + keys.dedup(); + if keys.is_empty() { + return Ok(Vec::new()); + } + if keys.len() > MAX_LAYER_READ_BATCH_COUNT { + return Err(StoreError::LayerReadBatchTooLarge { + layers: keys.len(), + limit: MAX_LAYER_READ_BATCH_COUNT, + }); + } + + let placeholders = (0..keys.len()).map(|_| "?").collect::>().join(","); + let directory_sql = format!( + " + SELECT l.id, l.source_id, l.width, l.height, + p.format, p.byte_length, length(p.payload) + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id IN ({placeholders}) + ORDER BY l.id + " + ); + let mut directory_stmt = conn.prepare_cached(&directory_sql)?; + let directory_rows = + directory_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, i64>(6)?, + )) + })?; + let mut facts = HashMap::with_capacity(keys.len()); + let mut batch_payload_bytes = 0_u64; + for row in directory_rows { + let (layer_id, source_id, width, height, format, declared_len, actual_len) = row?; + if format != GLYPH_LAYER_FORMAT { + return Err(StoreError::UnsupportedLayerFormat(format)); + } + if declared_len != actual_len { + return Err(StoreError::LayerDirectoryMismatch { + layer_id, + detail: format!("declared byte length {declared_len} != actual {actual_len}"), + }); + } + if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&actual_len) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(actual_len).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + batch_payload_bytes = batch_payload_bytes.saturating_add(actual_len as u64); + facts.insert(layer_id, (source_id, width, height, actual_len)); + } + drop(directory_stmt); + + for key in &keys { + if !facts.contains_key(key) { + return Err(StoreError::MissingEntity { + kind: "glyph layer", + id: key.clone(), + }); + } + } + if batch_payload_bytes > MAX_LAYER_READ_BATCH_PAYLOAD_BYTES { + return Err(StoreError::LayerReadBatchPayloadTooLarge { + bytes: batch_payload_bytes, + limit: MAX_LAYER_READ_BATCH_PAYLOAD_BYTES, + }); + } + + let payload_sql = format!( + " + SELECT layer_id, payload, length(payload) + FROM glyph_layer_payloads + WHERE layer_id IN ({placeholders}) + AND length(payload) <= {MAX_LAYER_PAYLOAD_BYTES} + ORDER BY layer_id + " + ); + let mut payload_stmt = conn.prepare_cached(&payload_sql)?; + let payload_rows = payload_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + })?; + let mut layers = Vec::with_capacity(keys.len()); + let mut loaded = HashSet::with_capacity(keys.len()); + for row in payload_rows { + let (layer_id, payload, actual_len) = row?; + let (source_id, width, height, expected_len) = + facts + .get(&layer_id) + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.clone(), + detail: "payload has no directory row".to_string(), + })?; + if actual_len != *expected_len || payload.len() as i64 != *expected_len { + return Err(StoreError::LayerDirectoryMismatch { + layer_id, + detail: "payload changed during bounded batch read".to_string(), + }); + } + let layer = font::unpack_glyph_layer(&payload)?; + let expected_id = font::LayerId::from_raw(layer_id.clone()); + validate_directory_facts(&expected_id, source_id, *width, *height, &layer)?; + loaded.insert(layer_id); + layers.push(layer); + } + drop(payload_stmt); + + for key in &keys { + if !loaded.contains(key) { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: key.clone(), + detail: "payload changed during bounded batch read".to_string(), + }); + } + } + + let component_sql = format!( + " + SELECT layer_id, id, base_glyph_id, order_index + FROM glyph_components + WHERE layer_id IN ({placeholders}) + ORDER BY layer_id, order_index, id + " + ); + let mut component_stmt = conn.prepare_cached(&component_sql)?; + let component_rows = + component_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + })?; + let mut components: HashMap> = HashMap::new(); + for row in component_rows { + let (layer_id, component_id, base_glyph_id, order_index) = row?; + components + .entry(layer_id) + .or_default() + .push((component_id, base_glyph_id, order_index)); + } + drop(component_stmt); + + for layer in &layers { + let indexed = components.remove(layer.id().as_str()).unwrap_or_default(); + validate_component_rows(layer, &indexed)?; + } + Ok(layers) +} + pub(crate) fn write_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, @@ -231,9 +413,65 @@ pub(crate) fn write_layer_in_tx( layer: &font::GlyphLayer, ) -> Result<(), StoreError> { let packed = font::pack_glyph_layer(layer)?; + write_packed_layer_in_tx(tx, glyph_id, name, layer, &packed) +} + +pub(crate) fn insert_packed_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + name: Option<&font::GlyphName>, + layer: &font::GlyphLayer, + packed: &shift_glyph_codec::PackedGlyphLayer, +) -> Result<(), StoreError> { let bytes = packed.as_bytes(); + let byte_length = i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes: bytes.len() as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + })?; + tx.prepare_cached( + "INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + )? + .execute(params![ + layer.id().to_string(), + glyph_id.to_string(), + layer.source_id().to_string(), + name.map(font::GlyphName::as_str), + layer.width(), + layer.height(), + ])?; + tx.prepare_cached( + "INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, ?3, ?4)", + )? + .execute(params![ + layer.id().to_string(), + GLYPH_LAYER_FORMAT, + bytes, + byte_length, + ])?; + for (order_index, component) in layer.components_iter().enumerate() { + tx.prepare_cached( + "INSERT INTO glyph_components (id, layer_id, base_glyph_id, order_index) VALUES (?1, ?2, ?3, ?4)", + )? + .execute(params![ + component.id().to_string(), + layer.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ])?; + } + Ok(()) +} - tx.execute( +pub(crate) fn write_packed_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + name: Option<&font::GlyphName>, + layer: &font::GlyphLayer, + packed: &shift_glyph_codec::PackedGlyphLayer, +) -> Result<(), StoreError> { + let bytes = packed.as_bytes(); + + tx.prepare_cached( " INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6) @@ -244,16 +482,16 @@ pub(crate) fn write_layer_in_tx( width = excluded.width, height = excluded.height ", - params![ - layer.id().to_string(), - glyph_id.to_string(), - layer.source_id().to_string(), - name.map(font::GlyphName::as_str), - layer.width(), - layer.height(), - ], - )?; - tx.execute( + )? + .execute(params![ + layer.id().to_string(), + glyph_id.to_string(), + layer.source_id().to_string(), + name.map(font::GlyphName::as_str), + layer.width(), + layer.height(), + ])?; + tx.prepare_cached( " INSERT INTO glyph_layer_payloads ( layer_id, format, payload, byte_length @@ -264,16 +502,16 @@ pub(crate) fn write_layer_in_tx( payload = excluded.payload, byte_length = excluded.byte_length ", - params![ - layer.id().to_string(), - GLYPH_LAYER_FORMAT, - bytes, - i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { - bytes: bytes.len() as u64, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - })?, - ], - )?; + )? + .execute(params![ + layer.id().to_string(), + GLYPH_LAYER_FORMAT, + bytes, + i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes: bytes.len() as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + })?, + ])?; replace_component_index(tx, layer)?; Ok(()) } @@ -307,24 +545,22 @@ fn replace_component_index( tx: &Transaction<'_>, layer: &font::GlyphLayer, ) -> Result<(), StoreError> { - tx.execute( - "DELETE FROM glyph_components WHERE layer_id = ?1", - [layer.id().to_string()], - )?; + tx.prepare_cached("DELETE FROM glyph_components WHERE layer_id = ?1")? + .execute([layer.id().to_string()])?; for (order_index, component) in layer.components_iter().enumerate() { - tx.execute( + tx.prepare_cached( " INSERT INTO glyph_components ( id, layer_id, base_glyph_id, order_index ) VALUES (?1, ?2, ?3, ?4) ", - params![ - component.id().to_string(), - layer.id().to_string(), - component.base_glyph_id().to_string(), - order_index as i64, - ], - )?; + )? + .execute(params![ + component.id().to_string(), + layer.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ])?; } Ok(()) } @@ -357,7 +593,7 @@ fn validate_directory_facts( } fn validate_component_index(conn: &Connection, layer: &font::GlyphLayer) -> Result<(), StoreError> { - let mut stmt = conn.prepare( + let mut stmt = conn.prepare_cached( " SELECT id, base_glyph_id, order_index FROM glyph_components @@ -374,6 +610,13 @@ fn validate_component_index(conn: &Connection, layer: &font::GlyphLayer) -> Resu )) })? .collect::, _>>()?; + validate_component_rows(layer, &indexed) +} + +fn validate_component_rows( + layer: &font::GlyphLayer, + indexed: &[(String, String, i64)], +) -> Result<(), StoreError> { let authored: Vec<_> = layer .components_iter() .enumerate() @@ -385,8 +628,7 @@ fn validate_component_index(conn: &Connection, layer: &font::GlyphLayer) -> Resu ) }) .collect(); - let matches = indexed == authored; - if !matches { + if indexed != authored { return Err(StoreError::StaleLayerReferenceIndex { layer_id: layer.id().to_string(), }); @@ -481,10 +723,11 @@ mod tests { ) .unwrap(); - store - .layer_stream_writer() + let mut writer = store.layer_stream_writer().unwrap(); + writer .write_layer(&glyph_id, Some(&glyph_name), &layer) .unwrap(); + writer.finish().unwrap(); let stored: Vec = store .conn @@ -499,6 +742,36 @@ mod tests { assert_eq!(font::pack_glyph_layer(&decoded).unwrap().as_bytes(), golden); } + #[test] + fn bounded_batch_load_matches_complete_font_layers() { + let (store, font) = populated_store(); + let mut layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect::>(); + layer_ids.push(layer_ids[0].clone()); + + let layers = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(layers.len(), layer_ids.len() - 1); + for layer in layers { + assert_eq!(font.layer(layer.id()).unwrap(), &layer); + } + } + + #[test] + fn bounded_batch_load_rejects_excessive_layer_counts_before_sql() { + let store = ShiftStore::open_memory_for_test().unwrap(); + let layer_ids = (0..=MAX_LAYER_READ_BATCH_COUNT) + .map(|index| font::LayerId::from_raw(format!("layer_{index}"))) + .collect::>(); + + assert!(matches!( + store.load_glyph_layers(&layer_ids), + Err(StoreError::LayerReadBatchTooLarge { .. }) + )); + } + #[test] fn directory_open_never_reads_malformed_payloads() { let (store, font) = populated_store(); @@ -522,6 +795,11 @@ mod tests { assert_eq!(directory.glyph_count(), font.glyph_count()); assert!(directory.layer(layer_id.clone()).unwrap().is_empty()); assert!(store.load_glyph_layer(layer_id).is_err()); + assert!( + store + .load_glyph_layers(std::slice::from_ref(layer_id)) + .is_err() + ); } #[test] @@ -544,6 +822,10 @@ mod tests { store.load_glyph_layer(&layer.id()), Err(StoreError::StaleLayerReferenceIndex { .. }) )); + assert!(matches!( + store.load_glyph_layers(&[layer.id()]), + Err(StoreError::StaleLayerReferenceIndex { .. }) + )); } #[test] @@ -697,14 +979,30 @@ mod tests { let glyph_name = glyph.glyph_name().clone(); drop(font); - store - .layer_stream_writer() + let mut writer = store.layer_stream_writer().unwrap(); + writer .write_layer(&glyph_id, Some(&glyph_name), &layer) .unwrap(); + writer.finish().unwrap(); assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); } + #[test] + fn abandoned_font_stream_rolls_back_header_and_glyphs() { + let (mut store, font) = populated_store(); + let before = store.list_glyph_layer_directory().unwrap(); + { + let mut writer = store.font_stream_writer(&font).unwrap(); + let glyph = font.glyphs().next().unwrap(); + writer + .write_glyph_batch(std::slice::from_ref(glyph)) + .unwrap(); + } + + assert_eq!(store.list_glyph_layer_directory().unwrap(), before); + } + #[test] fn cjk_scale_directory_open_is_payload_independent() { const GLYPH_COUNT: usize = 65_536; diff --git a/crates/shift-store/src/store.rs b/crates/shift-store/src/store.rs index 398cc350..42fe434f 100644 --- a/crates/shift-store/src/store.rs +++ b/crates/shift-store/src/store.rs @@ -1,3 +1,4 @@ pub struct ShiftStore { pub(crate) conn: rusqlite::Connection, + pub(crate) path: Option, } diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index 42af0ba5..7ff8a90d 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -1,34 +1,138 @@ +use rayon::prelude::*; +use rusqlite::Transaction; use shift_font as font; -use crate::{ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx}; +use crate::{ + ShiftStore, StoreError, + change_set::{ + insert_glyph_directory_in_tx, replace_font_header_in_tx, write_glyph_directory_in_tx, + }, + workspace_state::mark_workspace_dirty_in_tx, +}; -/// Bounded sink for format-specific importers. +/// Bounded transactional sink for format-specific importers. /// -/// The caller owns parsing and directory creation. Each call encodes one -/// authored layer, derives its relational reference rows, commits both, and -/// returns before the caller parses the next layer. No complete `Font` is -/// required by this API. +/// A foreign reader can write one glyph or layer at a time without building a +/// complete [`font::Font`]. The transaction commits only when [`Self::finish`] +/// is called; dropping the writer rolls the entire stream back. pub struct LayerStreamWriter<'store> { - store: &'store mut ShiftStore, + tx: Transaction<'store>, + next_glyph_order: i64, + mode: StreamMode, +} + +#[derive(Clone, Copy)] +enum StreamMode { + Update, + Replacement, } impl ShiftStore { - pub fn layer_stream_writer(&mut self) -> LayerStreamWriter<'_> { - LayerStreamWriter { store: self } + /// Starts a stream against the existing font directory. + pub fn layer_stream_writer(&mut self) -> Result, StoreError> { + let tx = self.conn.transaction()?; + let next_glyph_order = tx.query_row( + "SELECT COALESCE(MAX(order_index) + 1, 0) FROM glyphs", + [], + |row| row.get(0), + )?; + Ok(LayerStreamWriter { + tx, + next_glyph_order, + mode: StreamMode::Update, + }) + } + + /// Starts an atomic replacement using a glyph-free font header. + pub fn font_stream_writer( + &mut self, + font_header: &font::Font, + ) -> Result, StoreError> { + let tx = self.conn.transaction()?; + replace_font_header_in_tx(&tx, font_header)?; + Ok(LayerStreamWriter { + tx, + next_glyph_order: 0, + mode: StreamMode::Replacement, + }) } } impl LayerStreamWriter<'_> { + /// Writes one complete glyph and all of its currently authored layers. + pub fn write_glyph(&mut self, glyph: &font::Glyph) -> Result<(), StoreError> { + self.write_glyph_batch(std::slice::from_ref(glyph)) + } + + /// Packs a bounded glyph batch in parallel, then writes it in stable order. + pub fn write_glyph_batch(&mut self, glyphs: &[font::Glyph]) -> Result<(), StoreError> { + let packed_layers = glyphs + .par_iter() + .map(|glyph| { + glyph + .layers() + .values() + .map(|layer| font::pack_glyph_layer(layer).map(|packed| (layer.id(), packed))) + .collect::, _>>() + }) + .collect::, font::PackedLayerError>>()?; + + for (glyph, packed_layers) in glyphs.iter().zip(packed_layers) { + match self.mode { + StreamMode::Update => { + write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order)?; + } + StreamMode::Replacement => { + insert_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order)?; + } + } + for (layer_id, packed) in packed_layers { + let layer = + glyph + .layers() + .get(&layer_id) + .ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer_id.to_string(), + })?; + match self.mode { + StreamMode::Update => crate::packed_layer::write_packed_layer_in_tx( + &self.tx, + &glyph.id(), + Some(glyph.glyph_name()), + layer, + &packed, + )?, + StreamMode::Replacement => crate::packed_layer::insert_packed_layer_in_tx( + &self.tx, + &glyph.id(), + Some(glyph.glyph_name()), + layer, + &packed, + )?, + } + } + self.next_glyph_order += 1; + } + Ok(()) + } + + /// Writes or replaces one independently-addressed layer. pub fn write_layer( &mut self, glyph_id: &font::GlyphId, glyph_name: Option<&font::GlyphName>, layer: &font::GlyphLayer, ) -> Result<(), StoreError> { - let tx = self.store.conn.transaction()?; - crate::packed_layer::write_layer_in_tx(&tx, glyph_id, glyph_name, layer)?; - mark_workspace_dirty_in_tx(&tx)?; - tx.commit()?; + crate::packed_layer::write_layer_in_tx(&self.tx, glyph_id, glyph_name, layer) + } + + /// Atomically publishes every glyph and layer written by this stream. + pub fn finish(self) -> Result<(), StoreError> { + if matches!(self.mode, StreamMode::Update) { + mark_workspace_dirty_in_tx(&self.tx)?; + } + self.tx.commit()?; Ok(()) } } diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 17aafb6e..1cf68a9f 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -953,6 +953,53 @@ fn file_stores_run_wal_with_verified_pragmas() { std::fs::remove_dir_all(path.parent().unwrap()).ok(); } +#[test] +fn completed_import_store_restores_durable_wal_mode() { + let path = temp_store_path("import-pragmas"); + let font = shift_font::test_support::sample_font(); + let mut store = ShiftStore::open_for_import(&path).expect("open import store"); + let mut writer = store.font_stream_writer(&font).expect("begin import"); + for glyph in font.glyphs() { + writer.write_glyph(glyph).expect("write glyph"); + } + writer.finish().expect("commit import"); + store.finish_import().expect("finish import"); + drop(store); + + let conn = rusqlite::Connection::open(&path).expect("reopen raw"); + let journal: String = conn + .query_row("PRAGMA journal_mode", [], |row| row.get(0)) + .expect("journal_mode"); + assert_eq!(journal, "wal"); + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .expect("integrity_check"); + assert_eq!(integrity, "ok"); + + std::fs::remove_dir_all(path.parent().unwrap()).ok(); +} + +#[test] +fn import_mode_refuses_a_published_workspace_destination() { + let path = temp_store_path("occupied-import"); + let mut store = ShiftStore::open(&path).expect("open"); + store + .set_workspace_state(WorkspaceState::untitled(None)) + .expect("publish workspace"); + drop(store); + + let error = match ShiftStore::open_for_import(&path) { + Ok(_) => panic!("published store must be retained"), + Err(error) => error, + }; + assert!(matches!( + error, + shift_store::StoreError::ImportDestinationNotEmpty(existing) if existing == path + )); + + std::fs::remove_dir_all(path.parent().unwrap()).ok(); +} + #[test] fn reopen_preserves_written_contents_and_integrity() { let path = temp_store_path("reopen"); diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index d50a6918..ef33de1d 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -6,6 +6,7 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** `FontWorkspace` composes a directory-complete, payload-lazy `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. - **Architecture Invariant:** Resuming SQLite loads metadata and glyph/layer directory facts only. `acquire_glyphs` performs explicit bounded payload I/O; synchronous `font()` reads never initiate I/O. +- **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `LayerStreamWriter`; they never construct a complete geometry-resident `Font`. Format readers and canonical packing use Rayon, while SQLite has one transaction owner. - **Architecture Invariant:** A loaded layer is only a cache of already-committed authored state. `evict_glyphs` replaces it with a directory placeholder and cannot lose an edit. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. @@ -22,6 +23,8 @@ crates/shift-workspace/src/ new_workspace.rs -- creation options for a fresh workspace source_identity.rs -- package identity snapshots and source-save validation workspace.rs -- `FontWorkspace` and workspace errors +crates/shift-workspace/examples/ + profile_streaming_import.rs -- shared release-mode foreign/native import profiler ``` ## Key Types @@ -37,7 +40,7 @@ crates/shift-workspace/src/ `FontWorkspace::create(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`. -`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. Other supported font paths are imported through `shift-backends` into an unsaved workspace with no save target. +`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. TTF/OTF, UFO, and Designspace paths use a metadata/directory-first backend cursor, parse batches of at most 512 glyphs and 1,024 authored layers, parallel-pack those layers, and insert them into a disposable import connection. After the complete stream commits, `finish_import` syncs the database and restores edit-time WAL settings. The returned workspace contains directory placeholders and zero loaded layer payloads. This synchronous API still returns only after finalization; publishing the directory and binary packed-outline grid while import continues requires the separate app import-session boundary. Other supported foreign formats retain the eager compatibility path until they gain a bounded reader. `FontWorkspace::save()` succeeds for saved `.shift` workspaces and returns `NeedsSaveAs` for imported workspaces. `save_as(path)` creates a `.shift` package and makes it the save target. @@ -45,7 +48,19 @@ crates/shift-workspace/src/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. -`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, include_references)` fetches requested layers and can expand component closure from the relational reference index. Acquisition decodes one layer at a time into a COW candidate font, so peak temporary memory is bounded and a malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, include_references)` fetches requested layers and can expand component closure from the relational reference index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of packed bytes, then decodes each batch into a COW candidate font. Each batch scans SQLite and copies the complete identity index only once; a malformed request does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. + +## Profiling + +`profile_streaming_import` reports foreign-directory, parse, pack/write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: + +```bash +cargo build --release -p shift-workspace --example profile_streaming_import +/usr/bin/time -v target/release/examples/profile_streaming_import \ + /path/to/font-or-project /tmp/import.sqlite +``` + +Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. The SQLite writer remains single-threaded. ## Verification diff --git a/crates/shift-workspace/examples/profile_streaming_import.rs b/crates/shift-workspace/examples/profile_streaming_import.rs new file mode 100644 index 00000000..95247c32 --- /dev/null +++ b/crates/shift-workspace/examples/profile_streaming_import.rs @@ -0,0 +1,159 @@ +use std::{ + env, fs, + path::PathBuf, + time::{Duration, Instant}, +}; + +use shift_backends::{ImportBatchLimit, font_loader::FontLoader}; +use shift_store::{ShiftStore, WorkspaceState}; +use shift_workspace::FontWorkspace; + +fn ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn main() -> Result<(), Box> { + let batch_glyphs = env::var("SHIFT_IMPORT_BATCH_GLYPHS") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(512); + let batch_layers = env::var("SHIFT_IMPORT_BATCH_LAYERS") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(1_024); + let batch_limit = ImportBatchLimit::new(batch_glyphs, batch_layers); + + let source = PathBuf::from(env::args_os().nth(1).ok_or("missing source path")?); + let store_path = PathBuf::from(env::args_os().nth(2).ok_or("missing store path")?); + let _ = fs::remove_file(&store_path); + let source_str = source.to_str().ok_or("invalid source path")?; + let total_started = Instant::now(); + + let started = Instant::now(); + let mut import = FontLoader::new().stream_font(source_str)?; + println!("directory_ms={:.3}", ms(started.elapsed())); + let started = Instant::now(); + let directory = import.directory(); + println!("publish_directory_ms={:.3}", ms(started.elapsed())); + println!("declared_glyph_count={}", import.glyph_count()); + println!("published_glyph_count={}", directory.len()); + drop(directory); + println!("batch_glyph_limit={batch_glyphs}"); + println!("batch_layer_limit={batch_layers}"); + + let started = Instant::now(); + let mut store = ShiftStore::open_for_import(&store_path)?; + let mut writer = store.font_stream_writer(import.header())?; + println!("store_setup_ms={:.3}", ms(started.elapsed())); + + let pipeline_started = Instant::now(); + let (sender, receiver) = std::sync::mpsc::sync_channel(2); + let mut parse = Duration::ZERO; + let mut pack_and_write = Duration::ZERO; + let mut glyph_count = 0; + let mut batches = 0; + std::thread::scope(|scope| -> Result<(), Box> { + scope.spawn(move || { + loop { + let started = Instant::now(); + match import.next_batch(batch_limit) { + Ok(glyphs) if glyphs.is_empty() => break, + Ok(glyphs) => { + if sender.send(Ok((glyphs, started.elapsed()))).is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + } + } + }); + + for result in receiver { + let (glyphs, parse_elapsed) = result?; + parse += parse_elapsed; + glyph_count += glyphs.len(); + batches += 1; + + let started = Instant::now(); + writer.write_glyph_batch(&glyphs)?; + pack_and_write += started.elapsed(); + } + Ok(()) + })?; + println!("parse_ms={:.3}", ms(parse)); + println!("pack_and_write_ms={:.3}", ms(pack_and_write)); + println!("pipeline_wall_ms={:.3}", ms(pipeline_started.elapsed())); + println!("batch_count={batches}"); + println!("glyph_count={glyph_count}"); + + let started = Instant::now(); + writer.finish()?; + println!("commit_ms={:.3}", ms(started.elapsed())); + let started = Instant::now(); + store.finish_import()?; + println!("finalize_import_ms={:.3}", ms(started.elapsed())); + store.set_workspace_state(WorkspaceState::imported(&source, None))?; + + let started = Instant::now(); + let directory = store.load_font_directory()?; + println!("materialize_directory_ms={:.3}", ms(started.elapsed())); + println!("foreign_import_ms={:.3}", ms(total_started.elapsed())); + println!("materialized_glyph_count={}", directory.glyph_count()); + println!("store_bytes={}", fs::metadata(&store_path)?.len()); + let started = Instant::now(); + let layer_directory = store.list_glyph_layer_directory()?; + println!("layer_count={}", layer_directory.len()); + println!( + "packed_bytes={}", + layer_directory + .iter() + .map(|entry| entry.payload_byte_length) + .sum::() + ); + println!("inspection_ms={:.3}", ms(started.elapsed())); + drop(directory); + drop(store); + + let started = Instant::now(); + let mut workspace = FontWorkspace::resume(&store_path)?; + println!("native_resume_ms={:.3}", ms(started.elapsed())); + println!("resumed_glyph_count={}", workspace.font().glyph_count()); + println!("resumed_loaded_layers={}", workspace.loaded_layer_count()); + let first_glyph_id = workspace + .font() + .glyph_id_by_name(".notdef") + .or_else(|| workspace.font().glyphs().next().map(|glyph| glyph.id())); + if let Some(glyph_id) = first_glyph_id { + let layer_ids = workspace + .font() + .glyph(glyph_id.clone()) + .unwrap() + .layers() + .keys() + .cloned() + .collect::>(); + let started = Instant::now(); + workspace.acquire_glyphs(std::slice::from_ref(&glyph_id), false)?; + println!("one_glyph_acquire_ms={:.3}", ms(started.elapsed())); + println!("one_glyph_loaded_layers={}", workspace.loaded_layer_count()); + + let probe_store = ShiftStore::open(&store_path)?; + let started = Instant::now(); + let layers = probe_store.load_glyph_layers(&layer_ids)?; + println!("one_glyph_batch_decode_ms={:.3}", ms(started.elapsed())); + let mut probe_font = workspace.font().clone(); + let started = Instant::now(); + probe_font.replace_glyph_layers(layers)?; + println!("one_glyph_install_ms={:.3}", ms(started.elapsed())); + + let started = Instant::now(); + workspace.evict_glyphs(&[glyph_id])?; + println!("one_glyph_evict_ms={:.3}", ms(started.elapsed())); + } + Ok(()) +} diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 44deb491..82a54e4c 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -4,14 +4,20 @@ use std::{ path::{Path, PathBuf}, }; -use shift_backends::{FontExportRequest, FontExportResult, FontExporter, font_loader::FontLoader}; +use shift_backends::{ + FontExportRequest, FontExportResult, FontExporter, FontImport, ImportBatchLimit, + font_loader::FontLoader, +}; use shift_font::{ AppliedIntents, Axis, AxisId, FontChange, FontChangeSet, FontIntent, FontIntentSet, FontMetadata, Glyph, GlyphId, GlyphLayer, MetricDefinition, NamedInstance, Source, SourceId, TouchedLayer, error::CoreError, }; use shift_source::ShiftSourcePackage; -use shift_store::{ShiftStore, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState}; +use shift_store::{ + LayerStreamWriter, MAX_LAYER_READ_BATCH_COUNT, ShiftStore, SourceIdentitySnapshot, + WorkspaceSourceKind, WorkspaceState, +}; use crate::NewWorkspace; use crate::ledger::{GlyphIdentity, LayerPair, Ledger, LedgerEntry, LedgerStep}; @@ -729,6 +735,37 @@ impl FontWorkspace { let import_path_str = import_path .to_str() .ok_or_else(|| WorkspaceError::InvalidPathUtf8(import_path.to_path_buf()))?; + let extension = import_path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + if matches!(extension.as_str(), "ttf" | "otf" | "ufo" | "designspace") { + let import = FontLoader::new().stream_font(import_path_str)?; + let mut store = ShiftStore::open_for_import(store_path)?; + let mut writer = store.font_stream_writer(import.header())?; + write_import_batches(import, &mut writer, ImportBatchLimit::default())?; + writer.finish()?; + store.finish_import()?; + store.set_workspace_state(WorkspaceState::imported(import_path, None))?; + let font = store.load_font_directory()?; + let unloaded_layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect(); + + return Ok(Self { + font, + source: WorkspaceSource::Imported { + original_path: import_path.to_path_buf(), + }, + store, + ledger: Ledger::default(), + unloaded_layer_ids, + }); + } + let font = FontLoader::new().read_font(import_path_str)?; let mut store = ShiftStore::open(store_path)?; store.set_font_info(font_info_from_font(&font))?; @@ -782,27 +819,29 @@ impl FontWorkspace { &mut self, layer_ids: impl IntoIterator, ) -> Result<(), WorkspaceError> { - let layer_ids = layer_ids + let mut layer_ids = layer_ids .into_iter() .filter(|layer_id| self.unloaded_layer_ids.contains(layer_id)) - .collect::>(); + .collect::>(); + layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); + layer_ids.dedup(); if layer_ids.is_empty() { return Ok(()); } - // Decode and install one layer at a time into a COW font clone. Peak - // temporary memory is one payload, while malformed bytes or a - // cross-layer identity collision still leave the live cache unchanged. + // Decode and install bounded batches into a COW font clone. A malformed + // payload or cross-layer identity collision still leaves the live cache + // unchanged, while SQLite and the font index each process one batch. let mut next_font = self.font.clone(); - for layer_id in &layer_ids { - let layer = self.store.load_glyph_layer(layer_id)?.ok_or_else(|| { - WorkspaceError::CorruptWorkingStore(format!("missing payload for layer {layer_id}")) - })?; - next_font.replace_glyph_layer(layer)?; + for layer_ids in layer_ids.chunks(MAX_LAYER_READ_BATCH_COUNT) { + let layers = self.store.load_glyph_layers(layer_ids)?; + next_font.replace_glyph_layers(layers)?; } + self.font = next_font; - self.unloaded_layer_ids - .retain(|layer_id| !layer_ids.contains(layer_id)); + for layer_id in layer_ids { + self.unloaded_layer_ids.remove(&layer_id); + } Ok(()) } @@ -810,23 +849,37 @@ impl FontWorkspace { /// authored edit is committed before the live font swap, so eviction can /// never discard unpersisted state. pub fn evict_glyphs(&mut self, glyph_ids: &[GlyphId]) -> Result<(), WorkspaceError> { - let mut next_font = self.font.clone(); + let mut placeholders = Vec::new(); let mut evicted = Vec::new(); + let mut seen_layer_ids = HashSet::new(); for glyph_id in glyph_ids { let Some(glyph) = self.font.glyph(glyph_id.clone()) else { continue; }; for layer in glyph.layers().values().map(|layer| layer.as_ref()) { - if self.unloaded_layer_ids.contains(&layer.id()) { + if self.unloaded_layer_ids.contains(&layer.id()) + || !seen_layer_ids.insert(layer.id()) + { continue; } let mut placeholder = GlyphLayer::with_width(layer.id(), layer.source_id(), layer.width()); placeholder.set_height(layer.height()); - next_font.replace_glyph_layer(placeholder)?; + placeholders.push(placeholder); evicted.push(layer.id()); } } + + let mut next_font = self.font.clone(); + let mut batch = Vec::with_capacity(MAX_LAYER_READ_BATCH_COUNT); + for placeholder in placeholders { + batch.push(placeholder); + if batch.len() == MAX_LAYER_READ_BATCH_COUNT { + next_font.replace_glyph_layers(std::mem::take(&mut batch))?; + } + } + next_font.replace_glyph_layers(batch)?; + self.font = next_font; self.unloaded_layer_ids.extend(evicted); Ok(()) @@ -1277,6 +1330,37 @@ fn replay_glyph_layer( Ok(()) } +fn write_import_batches( + mut import: FontImport, + writer: &mut LayerStreamWriter<'_>, + batch_limit: ImportBatchLimit, +) -> Result<(), WorkspaceError> { + let (sender, receiver) = std::sync::mpsc::sync_channel(2); + std::thread::scope(|scope| -> Result<(), WorkspaceError> { + scope.spawn(move || { + loop { + match import.next_batch(batch_limit) { + Ok(glyphs) if glyphs.is_empty() => break, + Ok(glyphs) => { + if sender.send(Ok(glyphs)).is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + } + } + }); + + for glyphs in receiver { + writer.write_glyph_batch(&glyphs?)?; + } + Ok(()) + }) +} + fn font_info_from_font(font: &shift_font::Font) -> shift_store::FontInfo { let metadata = font.metadata(); let metrics = font.metrics(); diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 73538d07..393c44b4 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -153,7 +153,7 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { let source_path = fixture("apps/desktop/src/renderer/src/assets/fonts/Inter-VariableFont.ttf"); let store_path = temp.path().join("inter.sqlite"); - let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); let glyph_count = workspace.font().glyph_count(); let total_layers = workspace .font() @@ -173,6 +173,8 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { .take(8) .map(|glyph| glyph.id()) .collect::>(); + assert_eq!(workspace.loaded_layer_count(), 0); + workspace.acquire_glyphs(&requested, false).unwrap(); let expected = requested .iter() .flat_map(|glyph_id| { @@ -217,7 +219,7 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { let source_path = fixture("fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); let store_path = temp.path().join("mutatorsans.sqlite"); - let workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); let glyph_count = workspace.font().glyph_count(); let total_layers = workspace .font() @@ -231,10 +233,11 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { .font() .glyphs() .find(|glyph| { - glyph - .layers() - .values() - .any(|layer| !layer.components().is_empty()) + !workspace + .store() + .referenced_glyph_ids_for_glyph(&glyph.id()) + .unwrap() + .is_empty() }) .expect("fixture should contain a composite glyph") .id(); @@ -254,6 +257,10 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { .len() }) .sum::(); + assert_eq!(workspace.loaded_layer_count(), 0); + workspace + .acquire_glyphs(std::slice::from_ref(&root), true) + .unwrap(); let expected_root_layers = workspace .font() .glyph(root.clone()) @@ -400,7 +407,7 @@ fn resumed_layers_are_acquired_and_evictable_without_losing_authored_state() { assert_eq!(authored.contours_iter().next().unwrap().points().len(), 4); workspace - .evict_glyphs(std::slice::from_ref(&glyph_id)) + .evict_glyphs(&[glyph_id.clone(), glyph_id.clone()]) .unwrap(); assert_eq!(workspace.loaded_layer_count(), 0); assert!(workspace.font().layer(layer_id.clone()).unwrap().is_empty()); From be05d96b37409192831c69c84c4bb75549b4cb25 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 07:32:19 +0000 Subject: [PATCH 05/29] Make lazy layer residency explicit --- crates/shift-bridge/src/bridge.rs | 40 +++++- crates/shift-font/src/changes.rs | 34 +++++ crates/shift-font/src/intents.rs | 50 +++++++ crates/shift-store/src/packed_layer.rs | 30 +++++ crates/shift-workspace/src/layer_residency.rs | 60 +++++++++ crates/shift-workspace/src/ledger.rs | 38 +++++- crates/shift-workspace/src/lib.rs | 1 + crates/shift-workspace/src/workspace.rs | 124 ++++++++++-------- .../shift-workspace/tests/workspace_test.rs | 75 +++++++++++ 9 files changed, 386 insertions(+), 66 deletions(-) create mode 100644 crates/shift-workspace/src/layer_residency.rs diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 305c1fae..2ccbfe7b 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -543,13 +543,19 @@ impl Bridge { #[napi] pub fn get_glyphs(&self) -> errors::Result> { - let mut records: Vec<_> = self - .font()? + let workspace = self.workspace()?; + let mut component_references = workspace.glyph_component_references()?; + let mut records = workspace + .font() .glyphs() - .map(GlyphRecord::from) - .map(Into::into) - .collect(); - records.sort_by(|a: &NapiGlyphRecord, b: &NapiGlyphRecord| a.name.cmp(&b.name)); + .map(|glyph| { + let mut record = GlyphRecord::from(glyph); + record.component_base_glyph_ids = + component_references.remove(&glyph.id()).unwrap_or_default(); + NapiGlyphRecord::from(record) + }) + .collect::>(); + records.sort_by(|a, b| a.name.cmp(&b.name)); Ok(records) } @@ -2573,6 +2579,28 @@ mod tests { ); } + #[test] + fn get_glyphs_reads_component_references_without_payload_acquisition() { + let source_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/fonts/mutatorsans-variable/MutatorSans.designspace"); + let (_, store_path) = test_paths("component-directory"); + let mut bridge = Bridge::new(); + bridge + .open_workspace(source_path.to_string_lossy().into_owned(), store_path) + .unwrap(); + + assert_eq!(bridge.workspace().unwrap().loaded_layer_count(), 0); + assert!( + bridge + .get_glyphs() + .unwrap() + .iter() + .any(|glyph| !glyph.component_base_glyph_ids.is_empty()), + "directory records should retain component edges without loading payloads" + ); + assert_eq!(bridge.workspace().unwrap().loaded_layer_count(), 0); + } + fn default_source_id(bridge: &Bridge) -> String { bridge.get_sources().unwrap()[0].id.clone() } diff --git a/crates/shift-font/src/changes.rs b/crates/shift-font/src/changes.rs index 414751c3..e4b140c4 100644 --- a/crates/shift-font/src/changes.rs +++ b/crates/shift-font/src/changes.rs @@ -203,6 +203,40 @@ impl FontChange { layer: GlyphLayerValue::from(layer), }) } + + /// Layer identity touched by a payload-affecting change. + /// + /// Workspace persistence uses this exhaustive match as a safety boundary: + /// an unloaded directory placeholder must never be written back over its + /// canonical payload. + pub fn layer_id(&self) -> Option<&LayerId> { + match self { + Self::GlyphLayerCreated(change) => Some(&change.layer_id), + Self::GlyphLayerDeleted(change) => Some(&change.layer_id), + Self::LayerMetricsChanged(change) => Some(&change.layer_id), + Self::ContourAdded(change) => Some(&change.layer_id), + Self::ContourOpenClosedChanged(change) => Some(&change.layer_id), + Self::PointsAdded(change) => Some(&change.layer_id), + Self::PointsDeleted(change) => Some(&change.layer_id), + Self::PointSmoothChanged(change) => Some(&change.layer_id), + Self::PointPositionsChanged(change) => Some(&change.layer_id), + Self::AnchorPositionsChanged(change) => Some(&change.layer_id), + Self::LayerGeometryReplaced(change) => Some(&change.layer_id), + Self::FontMetadataUpdated(_) + | Self::AxisCreated(_) + | Self::AxisUpdated(_) + | Self::AxisDeleted(_) + | Self::AxisMappingsUpdated(_) + | Self::MetricDefinitionsUpdated(_) + | Self::NamedInstancesUpdated(_) + | Self::SourceCreated(_) + | Self::SourceUpdated(_) + | Self::SourceDeleted(_) + | Self::GlyphCreated(_) + | Self::GlyphDeleted(_) + | Self::GlyphIdentityChanged(_) => None, + } + } } /// Complete authored metadata snapshot after one replacement. diff --git a/crates/shift-font/src/intents.rs b/crates/shift-font/src/intents.rs index e3db1a30..102a9b8f 100644 --- a/crates/shift-font/src/intents.rs +++ b/crates/shift-font/src/intents.rs @@ -241,6 +241,56 @@ impl FontIntent { } } + /// Complete authored-layer read set required before applying this intent. + /// + /// Keeping this exhaustive match beside the intent vocabulary prevents a + /// new operation from accidentally mutating a directory placeholder. The + /// workspace still guards the persistence boundary if a dependency is + /// ever omitted here. + pub fn required_layer_ids(&self, font: &Font) -> Vec { + match self { + Self::AddPoints { layer_id, .. } + | Self::AddContour { layer_id, .. } + | Self::SetContourClosed { layer_id, .. } + | Self::MovePoints { layer_id, .. } + | Self::SetPointSmooth { layer_id, .. } + | Self::RemovePoints { layer_id, .. } + | Self::AddAnchors { layer_id, .. } + | Self::MoveAnchors { layer_id, .. } + | Self::RemoveAnchors { layer_id, .. } + | Self::ReverseContour { layer_id, .. } + | Self::TranslatePoints { layer_id, .. } + | Self::SetXAdvance { layer_id, .. } + | Self::ApplyBooleanOp { layer_id, .. } => vec![layer_id.clone()], + Self::CloneGlyphLayer { from_layer_id, .. } + | Self::MaterializeGlyphLayer { from_layer_id, .. } => { + vec![from_layer_id.clone()] + } + Self::DeleteSource { source_id } => font + .glyphs() + .filter_map(|glyph| { + glyph + .layer_for_source(source_id.clone()) + .map(GlyphLayer::id) + }) + .collect(), + Self::CreateGlyph { .. } + | Self::UpdateGlyph { .. } + | Self::UpdateFontMetadata { .. } + | Self::CreateAxis { .. } + | Self::UpdateAxis { .. } + | Self::DeleteAxis { .. } + | Self::SetAxisMappings { .. } + | Self::SetMetricDefinitions { .. } + | Self::CreateNamedInstance { .. } + | Self::UpdateNamedInstance { .. } + | Self::DeleteNamedInstance { .. } + | Self::CreateSource { .. } + | Self::UpdateSource { .. } + | Self::CreateGlyphLayer { .. } => Vec::new(), + } + } + /// Whether applying this intent changes layer structure (vs values only). /// Smooth flags live in structure, so they count. fn structural(&self) -> bool { diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index a007f17f..0333acee 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -93,6 +93,36 @@ impl ShiftStore { rows.collect::, _>>().map_err(Into::into) } + /// Returns every direct glyph-component edge from one ordered relational + /// scan. Glyphs without components are absent from the map. + pub fn glyph_component_references( + &self, + ) -> Result>, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT l.glyph_id, c.base_glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS l ON l.id = c.layer_id + ORDER BY l.glyph_id, c.base_glyph_id + ", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + font::GlyphId::from_raw(row.get::<_, String>(1)?), + )) + })?; + let mut references = HashMap::new(); + for row in rows { + let (glyph_id, base_glyph_id) = row?; + references + .entry(glyph_id) + .or_insert_with(Vec::new) + .push(base_glyph_id); + } + Ok(references) + } + /// Returns referenced glyph identities entirely from the relational index. pub fn referenced_glyph_ids_for_glyph( &self, diff --git a/crates/shift-workspace/src/layer_residency.rs b/crates/shift-workspace/src/layer_residency.rs new file mode 100644 index 00000000..872062c3 --- /dev/null +++ b/crates/shift-workspace/src/layer_residency.rs @@ -0,0 +1,60 @@ +use std::collections::HashSet; + +use shift_font::{Font, LayerId}; + +/// Tracks which directory-listed layers do not currently carry authored +/// geometry in the live font projection. +/// +/// Directory placeholders are an implementation detail of the current lazy +/// workspace. Keeping every residency transition here prevents a valid empty +/// layer from being mistaken for loaded authored state at persistence +/// boundaries. +#[derive(Default)] +pub(crate) struct LayerResidency { + unloaded: HashSet, +} + +impl LayerResidency { + pub(crate) fn with_unloaded(layer_ids: impl IntoIterator) -> Self { + Self { + unloaded: layer_ids.into_iter().collect(), + } + } + + pub(crate) fn is_unloaded(&self, layer_id: &LayerId) -> bool { + self.unloaded.contains(layer_id) + } + + pub(crate) fn requested_unloaded( + &self, + layer_ids: impl IntoIterator, + ) -> Vec { + layer_ids + .into_iter() + .filter(|layer_id| self.is_unloaded(layer_id)) + .collect() + } + + pub(crate) fn unloaded_layer_ids(&self) -> impl Iterator { + self.unloaded.iter() + } + + pub(crate) fn mark_loaded(&mut self, layer_ids: impl IntoIterator) { + for layer_id in layer_ids { + self.unloaded.remove(&layer_id); + } + } + + pub(crate) fn mark_unloaded(&mut self, layer_ids: impl IntoIterator) { + self.unloaded.extend(layer_ids); + } + + pub(crate) fn retain_directory_layers(&mut self, font: &Font) { + self.unloaded + .retain(|layer_id| font.layer(layer_id.clone()).is_some()); + } + + pub(crate) fn loaded_count(&self, directory_layer_count: usize) -> usize { + directory_layer_count.saturating_sub(self.unloaded.len()) + } +} diff --git a/crates/shift-workspace/src/ledger.rs b/crates/shift-workspace/src/ledger.rs index 9ee2a07c..1f96ba49 100644 --- a/crates/shift-workspace/src/ledger.rs +++ b/crates/shift-workspace/src/ledger.rs @@ -8,8 +8,8 @@ //! crash; a SQLite ledger table is the later upgrade if that ever matters. use shift_font::{ - Axis, AxisMapping, FontMetadata, Glyph, GlyphId, GlyphLayer, GlyphName, MetricDefinition, - NamedInstance, Source, SourceId, + Axis, AxisMapping, FontMetadata, Glyph, GlyphId, GlyphLayer, GlyphName, LayerId, + MetricDefinition, NamedInstance, Source, SourceId, }; /// Generous bound so a marathon session cannot grow memory unboundedly; @@ -95,6 +95,40 @@ pub struct LedgerEntry { pub steps: Vec, } +impl LedgerEntry { + /// Layers whose persisted current values may be needed before replay. + /// Snapshot-backed replay can then replace loaded authored state without + /// leaving workspace residency bookkeeping pointed at a placeholder. + pub(crate) fn layer_ids(&self) -> Vec { + self.steps + .iter() + .flat_map(|step| match step { + LedgerStep::Layers(pairs) => pairs + .iter() + .flat_map(|pair| [pair.pre.id(), pair.post.id()]) + .collect(), + LedgerStep::Glyph { pre, post } => pre + .iter() + .chain(post.iter()) + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect(), + LedgerStep::GlyphLayer { pre, post, .. } => pre + .iter() + .chain(post.iter()) + .map(|layer| layer.id()) + .collect(), + LedgerStep::FontMetadata { .. } + | LedgerStep::Axis { .. } + | LedgerStep::AxisMappings { .. } + | LedgerStep::MetricDefinitions { .. } + | LedgerStep::NamedInstances { .. } + | LedgerStep::Source { .. } + | LedgerStep::GlyphIdentity { .. } => Vec::new(), + }) + .collect() + } +} + #[derive(Default)] pub struct Ledger { undo: Vec, diff --git a/crates/shift-workspace/src/lib.rs b/crates/shift-workspace/src/lib.rs index 3306d266..05ea4ba2 100644 --- a/crates/shift-workspace/src/lib.rs +++ b/crates/shift-workspace/src/lib.rs @@ -1,3 +1,4 @@ +mod layer_residency; mod ledger; mod new_workspace; mod source_identity; diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 82a54e4c..42a0adde 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashSet, + collections::{HashMap, HashSet}, io, path::{Path, PathBuf}, }; @@ -10,8 +10,8 @@ use shift_backends::{ }; use shift_font::{ AppliedIntents, Axis, AxisId, FontChange, FontChangeSet, FontIntent, FontIntentSet, - FontMetadata, Glyph, GlyphId, GlyphLayer, MetricDefinition, NamedInstance, Source, SourceId, - TouchedLayer, error::CoreError, + FontMetadata, Glyph, GlyphId, GlyphLayer, LayerId, MetricDefinition, NamedInstance, Source, + SourceId, TouchedLayer, error::CoreError, }; use shift_source::ShiftSourcePackage; use shift_store::{ @@ -20,6 +20,7 @@ use shift_store::{ }; use crate::NewWorkspace; +use crate::layer_residency::LayerResidency; use crate::ledger::{GlyphIdentity, LayerPair, Ledger, LedgerEntry, LedgerStep}; use crate::source_identity::{ PackageDraft, PackageIdentity, package_identity, source_identity_snapshot, @@ -58,6 +59,9 @@ pub enum WorkspaceError { #[error("corrupt working store: {0}")] CorruptWorkingStore(String), + #[error("refusing to persist unloaded glyph layer {0}")] + UnloadedLayerMutation(LayerId), + #[error("invalid UTF-8 in workspace path: {0}")] InvalidPathUtf8(PathBuf), @@ -77,7 +81,7 @@ pub struct FontWorkspace { source: WorkspaceSource, store: ShiftStore, ledger: Ledger, - unloaded_layer_ids: HashSet, + residency: LayerResidency, } impl FontWorkspace { @@ -97,7 +101,7 @@ impl FontWorkspace { source: WorkspaceSource::Untitled, store, ledger: Ledger::default(), - unloaded_layer_ids: HashSet::new(), + residency: LayerResidency::default(), }) } @@ -157,10 +161,10 @@ impl FontWorkspace { .workspace_state()? .ok_or_else(|| WorkspaceError::CorruptWorkingStore("missing workspace_state".into()))?; let font = store.load_font_directory()?; - let unloaded_layer_ids = font - .glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()) - .collect(); + let residency = LayerResidency::with_unloaded( + font.glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()), + ); let source = source_from_workspace_state(&state)?; Ok(Self { @@ -168,7 +172,7 @@ impl FontWorkspace { source, store, ledger: Ledger::default(), - unloaded_layer_ids, + residency, }) } @@ -239,26 +243,11 @@ impl FontWorkspace { set: FontIntentSet, label: Option, ) -> Result { - let mut required_layers = Vec::new(); - for intent in &set.intents { - if let Some(layer_id) = intent.layer_id() { - required_layers.push(layer_id.clone()); - } - match intent { - FontIntent::CloneGlyphLayer { from_layer_id, .. } - | FontIntent::MaterializeGlyphLayer { from_layer_id, .. } => { - required_layers.push(from_layer_id.clone()); - } - FontIntent::DeleteSource { source_id } => { - required_layers.extend(self.font.glyphs().filter_map(|glyph| { - glyph - .layer_for_source(source_id.clone()) - .map(shift_font::GlyphLayer::id) - })); - } - _ => {} - } - } + let required_layers = set + .intents + .iter() + .flat_map(|intent| intent.required_layer_ids(&self.font)) + .collect::>(); self.acquire_layers(required_layers)?; let mut pre = FontLevelPreState::default(); @@ -526,6 +515,8 @@ impl FontWorkspace { entry: &LedgerEntry, side: ReplaySide, ) -> Result { + self.acquire_layers(entry.layer_ids())?; + let mut named_instances = None; let mut metric_definitions = None; let mut steps = entry @@ -635,11 +626,19 @@ impl FontWorkspace { next_font: shift_font::Font, change_set: FontChangeSet, ) -> Result<(), WorkspaceError> { + if let Some(layer_id) = change_set + .changes + .iter() + .filter_map(FontChange::layer_id) + .find(|layer_id| self.residency.is_unloaded(layer_id)) + { + return Err(WorkspaceError::UnloadedLayerMutation(layer_id.clone())); + } + self.store .apply_change_set_with_font(&change_set, &next_font)?; self.font = next_font; - self.unloaded_layer_ids - .retain(|layer_id| self.font.layer(layer_id.clone()).is_some()); + self.residency.retain_directory_layers(&self.font); Ok(()) } @@ -723,7 +722,7 @@ impl FontWorkspace { }, store, ledger: Ledger::default(), - unloaded_layer_ids: HashSet::new(), + residency: LayerResidency::default(), }) } @@ -750,10 +749,10 @@ impl FontWorkspace { store.finish_import()?; store.set_workspace_state(WorkspaceState::imported(import_path, None))?; let font = store.load_font_directory()?; - let unloaded_layer_ids = font - .glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()) - .collect(); + let residency = LayerResidency::with_unloaded( + font.glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()), + ); return Ok(Self { font, @@ -762,7 +761,7 @@ impl FontWorkspace { }, store, ledger: Ledger::default(), - unloaded_layer_ids, + residency, }); } @@ -779,7 +778,7 @@ impl FontWorkspace { }, store, ledger: Ledger::default(), - unloaded_layer_ids: HashSet::new(), + residency: LayerResidency::default(), }) } @@ -811,18 +810,19 @@ impl FontWorkspace { } pub fn acquire_all_layers(&mut self) -> Result<(), WorkspaceError> { - let layer_ids = self.unloaded_layer_ids.iter().cloned().collect::>(); + let layer_ids = self + .residency + .unloaded_layer_ids() + .cloned() + .collect::>(); self.acquire_layers(layer_ids) } fn acquire_layers( &mut self, - layer_ids: impl IntoIterator, + layer_ids: impl IntoIterator, ) -> Result<(), WorkspaceError> { - let mut layer_ids = layer_ids - .into_iter() - .filter(|layer_id| self.unloaded_layer_ids.contains(layer_id)) - .collect::>(); + let mut layer_ids = self.residency.requested_unloaded(layer_ids); layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); layer_ids.dedup(); if layer_ids.is_empty() { @@ -839,9 +839,7 @@ impl FontWorkspace { } self.font = next_font; - for layer_id in layer_ids { - self.unloaded_layer_ids.remove(&layer_id); - } + self.residency.mark_loaded(layer_ids); Ok(()) } @@ -857,9 +855,7 @@ impl FontWorkspace { continue; }; for layer in glyph.layers().values().map(|layer| layer.as_ref()) { - if self.unloaded_layer_ids.contains(&layer.id()) - || !seen_layer_ids.insert(layer.id()) - { + if self.residency.is_unloaded(&layer.id()) || !seen_layer_ids.insert(layer.id()) { continue; } let mut placeholder = @@ -881,21 +877,33 @@ impl FontWorkspace { next_font.replace_glyph_layers(batch)?; self.font = next_font; - self.unloaded_layer_ids.extend(evicted); + self.residency.mark_unloaded(evicted); Ok(()) } pub fn loaded_layer_count(&self) -> usize { - self.font - .glyphs() - .map(|glyph| glyph.layers().len()) - .sum::() - .saturating_sub(self.unloaded_layer_ids.len()) + let directory_layer_count = self.font.glyphs().map(|glyph| glyph.layers().len()).sum(); + self.residency.loaded_count(directory_layer_count) + } + + pub fn glyph_component_references( + &self, + ) -> Result>, WorkspaceError> { + self.store.glyph_component_references().map_err(Into::into) + } + + pub fn referenced_glyph_ids_for_glyph( + &self, + glyph_id: &GlyphId, + ) -> Result, WorkspaceError> { + self.store + .referenced_glyph_ids_for_glyph(glyph_id) + .map_err(Into::into) } pub fn dependent_glyph_ids_for_layers( &self, - layer_ids: &[shift_font::LayerId], + layer_ids: &[LayerId], ) -> Result, WorkspaceError> { self.store .dependent_glyph_ids_for_layers(layer_ids) diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 393c44b4..3ca7e27c 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -418,6 +418,81 @@ fn resumed_layers_are_acquired_and_evictable_without_losing_authored_state() { assert_eq!(workspace.font().layer(layer_id).unwrap(), &authored); } +#[test] +fn intent_on_directory_layer_acquires_before_persisting() { + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("working.sqlite"); + let mut workspace = FontWorkspace::create_untitled(&store_path, NewWorkspace::new()).unwrap(); + let source_id = workspace.font().default_source_id().unwrap(); + let glyph_id = create_glyph(&mut workspace, "A", vec![65]); + let layer_id = create_glyph_layer(&mut workspace, glyph_id.clone(), source_id); + add_square_contour(&mut workspace, &layer_id, (10.0, 20.0), 100.0); + drop(workspace); + + let mut workspace = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(workspace.loaded_layer_count(), 0); + workspace + .apply( + FontIntentSet { + intents: vec![FontIntent::SetXAdvance { + layer_id: layer_id.clone(), + width: 777.0, + }], + }, + None, + ) + .unwrap(); + + let layer = workspace.font().layer(layer_id.clone()).unwrap(); + assert_eq!(workspace.loaded_layer_count(), 1); + assert_eq!(layer.width(), 777.0); + assert_eq!(layer.contours_iter().next().unwrap().points().len(), 4); + drop(workspace); + + let mut resumed = FontWorkspace::resume(&store_path).unwrap(); + resumed + .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .unwrap(); + let layer = resumed.font().layer(layer_id).unwrap(); + assert_eq!(layer.width(), 777.0); + assert_eq!(layer.contours_iter().next().unwrap().points().len(), 4); +} + +#[test] +fn undo_after_eviction_reacquires_and_tracks_restored_layer_as_loaded() { + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("working.sqlite"); + let mut workspace = FontWorkspace::create_untitled(&store_path, NewWorkspace::new()).unwrap(); + let source_id = workspace.font().default_source_id().unwrap(); + let glyph_id = create_glyph(&mut workspace, "A", vec![65]); + let layer_id = create_glyph_layer(&mut workspace, glyph_id.clone(), source_id); + add_square_contour(&mut workspace, &layer_id, (10.0, 20.0), 100.0); + let original_width = workspace.font().layer(layer_id.clone()).unwrap().width(); + + workspace + .apply( + FontIntentSet { + intents: vec![FontIntent::SetXAdvance { + layer_id: layer_id.clone(), + width: 777.0, + }], + }, + Some("Change advance".to_string()), + ) + .unwrap(); + workspace + .evict_glyphs(std::slice::from_ref(&glyph_id)) + .unwrap(); + assert_eq!(workspace.loaded_layer_count(), 0); + + workspace.undo().unwrap().expect("advance edit should undo"); + + let layer = workspace.font().layer(layer_id).unwrap(); + assert_eq!(workspace.loaded_layer_count(), 1); + assert_eq!(layer.width(), original_width); + assert_eq!(layer.contours_iter().next().unwrap().points().len(), 4); +} + #[test] fn resume_package_workspace_can_save_unsaved_store_state() { let temp = tempfile::tempdir().unwrap(); From 00e535f6d3d33e3a27813aba40289f896cc05eff Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 07:42:31 +0000 Subject: [PATCH 06/29] Route eager font loading through streams --- crates/shift-backends/src/binary/mod.rs | 8 +- crates/shift-backends/src/binary/reader.rs | 65 +-- .../shift-backends/src/designspace/reader.rs | 469 +----------------- crates/shift-backends/src/errors.rs | 3 + crates/shift-backends/src/font_loader.rs | 129 +++-- crates/shift-backends/src/import.rs | 36 +- crates/shift-backends/src/ufo/reader.rs | 85 +--- crates/shift-backends/tests/loading.rs | 17 +- crates/shift-workspace/src/workspace.rs | 58 ++- 9 files changed, 168 insertions(+), 702 deletions(-) diff --git a/crates/shift-backends/src/binary/mod.rs b/crates/shift-backends/src/binary/mod.rs index cbd75cd0..b45fd929 100644 --- a/crates/shift-backends/src/binary/mod.rs +++ b/crates/shift-backends/src/binary/mod.rs @@ -1,9 +1,8 @@ mod reader; -pub(crate) use reader::stream_font_file; - use crate::errors::{FormatBackendError, FormatBackendResult}; use crate::font_loader::FontAdaptor; +use crate::import::GlyphStream; use shift_font::Font; pub struct BytesFontAdaptor; @@ -16,4 +15,9 @@ impl FontAdaptor for BytesFontAdaptor { fn write_font(&self, _font: &Font, _path: &str) -> FormatBackendResult<()> { Err(FormatBackendError::WriteUnsupported) } + + fn stream(&self, path: &str) -> FormatBackendResult)>> { + let (header, stream) = reader::stream_font_file(path)?; + Ok(Some((header, Box::new(stream)))) + } } diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index 0c671d3a..b8fabe0b 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use crate::{ errors::{FormatBackendError, FormatBackendResult}, - import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, + import::{collect_streamed_font, GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, }; use rayon::prelude::*; use shift_font::{ @@ -20,11 +20,8 @@ use skrifa::{ use crate::metrics::set_metric_position; pub fn read_font_file(path: &str) -> FormatBackendResult { - let bytes = std::fs::read(path) - .map_err(|e| FormatBackendError::Binary(format!("failed to read '{path}': {e}")))?; - let font = FontRef::new(&bytes) - .map_err(|e| FormatBackendError::Binary(format!("failed to parse '{path}': {e}")))?; - font_from_skrifa(&font) + let (header, mut stream) = stream_font_file(path)?; + collect_streamed_font(header, &mut stream) } #[derive(Default)] @@ -155,60 +152,6 @@ fn detect_smooth_points(contours: &mut [Contour]) { } } -fn font_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { - let outlines = font.outline_glyphs(); - let char_map = font.charmap(); - let hmtx = font - .hmtx() - .map_err(|e| FormatBackendError::Binary(format!("failed to read hmtx table: {e}")))?; - - let mut ir_font = font_header_from_skrifa(font)?; - let default_source_id = ir_font - .default_source_id() - .expect("new font should have a default source"); - - for (unicode, glyph_id) in char_map.mappings() { - let outline = outlines.get(glyph_id).ok_or_else(|| { - FormatBackendError::Binary(format!( - "missing outline for glyph {glyph_id} (U+{unicode:04X})" - )) - })?; - let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); - let mut pen = ShiftPen::default(); - outline.draw(settings, &mut pen).map_err(|e| { - FormatBackendError::Binary(format!( - "failed to draw outline for glyph {glyph_id} (U+{unicode:04X}): {e}" - )) - })?; - - let advance_width = hmtx.advance(glyph_id).ok_or_else(|| { - FormatBackendError::Binary(format!( - "missing advance width for glyph {glyph_id} (U+{unicode:04X})" - )) - })?; - - let glyph_name = char::from_u32(unicode) - .map(|c| c.to_string()) - .unwrap_or_else(|| format!("uni{unicode:04X}")); - - let mut glyph = Glyph::with_unicode(glyph_name, unicode); - let mut layer = GlyphLayer::with_width( - LayerId::new(), - default_source_id.clone(), - advance_width as f64, - ); - let mut contours = pen.contours(); - detect_smooth_points(&mut contours); - for contour in contours { - layer.add_contour(contour); - } - glyph.set_layer(layer); - ir_font.insert_glyph(glyph)?; - } - - Ok(ir_font) -} - fn font_header_from_skrifa(font: &FontRef<'_>) -> FormatBackendResult { let metrics = font.metrics(Size::unscaled(), LocationRef::default()); let mut ir_font = Font::new(); @@ -636,7 +579,7 @@ mod tests { ) .unwrap(); - let font = font_from_skrifa(&font_ref).unwrap(); + let font = read_font_file(path.to_str().unwrap()).unwrap(); let glyph = font .glyphs_by_unicode('O' as u32) .next() diff --git a/crates/shift-backends/src/designspace/reader.rs b/crates/shift-backends/src/designspace/reader.rs index 8eca892c..81e33109 100644 --- a/crates/shift-backends/src/designspace/reader.rs +++ b/crates/shift-backends/src/designspace/reader.rs @@ -1,18 +1,14 @@ -use super::axis_labels; use super::error::{DesignspaceError, DesignspaceResult}; -use crate::errors::{FormatBackendError, FormatBackendResult}; -use crate::metrics::copy_source_metrics; +use crate::errors::FormatBackendResult; +use crate::import::collect_streamed_font; use crate::traits::FontReader; -use crate::ufo::UfoReader; use norad::designspace::DesignSpaceDocument; use quick_xml::events::{BytesStart, Event}; use quick_xml::Reader; use shift_font::{ - Axis, AxisId, AxisMapping, AxisMappingId, AxisMappingPoint, Component, Font, GlyphLayer, - LayerId, Location, NamedInstance, Source, SourceId, + Axis, AxisId, AxisMapping, AxisMappingId, AxisMappingPoint, Font, Location, NamedInstance, }; use std::collections::{HashMap, HashSet}; -use std::fs; use std::path::Path; pub struct DesignspaceReader; @@ -32,213 +28,13 @@ impl Default for DesignspaceReader { impl FontReader for DesignspaceReader { fn load(&self, path: &str) -> FormatBackendResult { self.load_designspace(path) - .map_err(FormatBackendError::from) } } impl DesignspaceReader { - fn load_designspace(&self, path: &str) -> DesignspaceResult { - let ds_path = Path::new(path); - let ds_dir = ds_path - .parent() - .ok_or_else(|| DesignspaceError::MissingParent { - path: ds_path.to_path_buf(), - })?; - let xml = fs::read_to_string(ds_path).map_err(|source| DesignspaceError::ReadFile { - path: ds_path.to_path_buf(), - source, - })?; - let mut axis_labels = axis_labels::parse(&xml)?; - - let doc = match DesignSpaceDocument::load(ds_path) { - Ok(doc) => doc, - Err(error) => { - let original_error = error.to_string(); - return load_axisless_designspace(ds_path, ds_dir, &original_error).map_err( - |fallback_error| DesignspaceError::LoadDesignspace { - path: ds_path.to_path_buf(), - details: format!("{original_error}; {fallback_error}"), - }, - ); - } - }; - - if doc.sources.is_empty() { - return Err(DesignspaceError::NoSources); - } - - let default_idx = - find_default_source_index(&doc).ok_or(DesignspaceError::MissingDefaultSource)?; - - // Load the default source first to establish the base font. - let default_ds_source = &doc.sources[default_idx]; - let default_ufo_path = ds_dir.join(&default_ds_source.filename); - let default_ufo_str = - default_ufo_path - .to_str() - .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { - path: default_ufo_path.clone(), - })?; - - let ufo_reader = UfoReader::new(); - let mut font = - ufo_reader - .load(default_ufo_str) - .map_err(|source| DesignspaceError::LoadUfo { - path: default_ufo_path.clone(), - details: source.to_string(), - })?; - let default_ufo_source_id = source_layer_id(&font, default_ds_source)?; - let default_ufo_metrics = font - .default_source() - .cloned() - .ok_or(DesignspaceError::NoSources)?; - font.clear_sources(); - - if let Some(ref family) = default_ds_source.familyname { - font.metadata_mut().family_name = Some(family.clone()); - } - - // Add axes. - for ds_axis in &doc.axes { - let mut axis = if let Some(values) = &ds_axis.values { - let mut values = values.iter().map(|value| *value as f64).collect::>(); - values.sort_by(f64::total_cmp); - values.dedup(); - Axis::discrete_with_id( - AxisId::new(), - ds_axis.tag.clone(), - ds_axis.name.clone(), - values, - ds_axis.default as f64, - ) - } else { - let (minimum, maximum) = derive_axis_range(ds_axis); - Axis::new( - ds_axis.tag.clone(), - ds_axis.name.clone(), - minimum, - ds_axis.default as f64, - maximum, - ) - }; - axis.set_hidden(ds_axis.hidden); - axis.set_labels( - axis_labels - .remove(ds_axis.name.as_str()) - .unwrap_or_default(), - ); - axis.validate()?; - font.add_axis(axis)?; - } - font.set_axis_mappings(axis_mappings_from_designspace(&doc, font.axes())?)?; - font.set_named_instances(named_instances_from_designspace(&doc, font.axes())?)?; - - // Register the default source. - let mut source_names = HashSet::new(); - let default_location = - location_from_dimensions(&default_ds_source.location, &doc, font.axes()); - let default_name = source_name( - default_ds_source, - font.metadata().style_name.as_deref(), - &source_names, - default_idx, - ); - source_names.insert(default_name.clone()); - let mut default_source = Source::with_filename( - default_name, - default_location, - default_ds_source.filename.clone(), - ); - default_source.set_layer_name(default_ds_source.layer.clone()); - let metric_definitions = font.metric_definitions().to_vec(); - copy_source_metrics( - &metric_definitions, - &default_ufo_metrics, - &metric_definitions, - &mut default_source, - ); - let default_source_id = font.add_source(default_source); - font.set_default_source_id(default_source_id.clone()); - move_glyph_layers_to_source(&mut font, default_ufo_source_id, default_source_id)?; - - // Cache loaded UFO fonts so we don't re-read the same file for support layers. - let mut ufo_cache: HashMap = HashMap::new(); - - // Load each non-default source. - for (idx, ds_source) in doc.sources.iter().enumerate() { - if idx == default_idx { - continue; - } - - let ufo_path = ds_dir.join(&ds_source.filename); - let ufo_str = ufo_path - .to_str() - .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { - path: ufo_path.clone(), - })? - .to_string(); - - let source_font = match ufo_cache.get(&ufo_str) { - Some(f) => f, - None => { - let loaded = - ufo_reader - .load(&ufo_str) - .map_err(|source| DesignspaceError::LoadUfo { - path: ufo_path.clone(), - details: source.to_string(), - })?; - ufo_cache.insert(ufo_str.clone(), loaded); - ufo_cache.get(&ufo_str).unwrap() - } - }; - - // Determine which layer from the source UFO to read. - let source_source_id = source_layer_id(source_font, ds_source)?; - - let name = source_name( - ds_source, - source_font.metadata().style_name.as_deref(), - &source_names, - idx, - ); - source_names.insert(name.clone()); - let location = location_from_dimensions(&ds_source.location, &doc, font.axes()); - let mut source = Source::with_filename(name, location, ds_source.filename.clone()); - source.set_layer_name(ds_source.layer.clone()); - let imported_metrics = source_font - .default_source() - .ok_or(DesignspaceError::NoSources)?; - copy_source_metrics( - source_font.metric_definitions(), - imported_metrics, - font.metric_definitions(), - &mut source, - ); - let source_id = font.add_source(source); - - // Copy glyphs from the resolved layer into the new layer. - for source_glyph in source_font.glyphs() { - if let Some(source_layer) = source_glyph.layer_for_source(source_source_id.clone()) - { - if let Some(glyph_id) = font.glyph_id_by_name(source_glyph.name()) { - font.insert_glyph_layer( - glyph_id, - clone_layer_with_remapped_components( - source_layer, - &font, - LayerId::new(), - source_id.clone(), - )?, - )?; - } - } - } - } - - remove_glyph_layers_without_source(&mut font)?; - Ok(font) + fn load_designspace(&self, path: &str) -> FormatBackendResult { + let (header, mut stream) = super::stream_font(path)?; + collect_streamed_font(header, &mut stream) } } @@ -471,160 +267,6 @@ pub(super) struct AxislessSource { pub(super) layer: Option, } -fn load_axisless_designspace( - ds_path: &Path, - ds_dir: &Path, - original_error: &str, -) -> DesignspaceResult { - let xml = fs::read_to_string(ds_path).map_err(|source| DesignspaceError::ReadFile { - path: ds_path.to_path_buf(), - source, - })?; - let sources = parse_axisless_sources(&xml).map_err(|fallback_error| { - DesignspaceError::LoadDesignspace { - path: ds_path.to_path_buf(), - details: format!( - "{fallback_error}; axisless fallback was used after parser error: {original_error}" - ), - } - })?; - if sources.is_empty() { - return Err(DesignspaceError::NoSources); - } - - let default_source = &sources[0]; - let default_ufo_path = ds_dir.join(&default_source.filename); - let default_ufo_str = - default_ufo_path - .to_str() - .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { - path: default_ufo_path.clone(), - })?; - - let ufo_reader = UfoReader::new(); - let mut font = - ufo_reader - .load(default_ufo_str) - .map_err(|source| DesignspaceError::LoadUfo { - path: default_ufo_path.clone(), - details: source.to_string(), - })?; - let default_ufo_source_id = font - .default_source_id() - .ok_or(DesignspaceError::NoSources)?; - let default_ufo_source = font - .default_source() - .cloned() - .ok_or(DesignspaceError::NoSources)?; - font.clear_sources(); - - if let Some(family) = &default_source.familyname { - font.metadata_mut().family_name = Some(family.clone()); - } - - let mut source_names = HashSet::new(); - let default_name = axisless_source_name( - default_source, - font.metadata().style_name.as_deref(), - &source_names, - 0, - ); - source_names.insert(default_name.clone()); - let mut new_default_source = Source::with_filename( - default_name, - Location::new(), - default_source.filename.clone(), - ); - let metric_definitions = font.metric_definitions().to_vec(); - copy_source_metrics( - &metric_definitions, - &default_ufo_source, - &metric_definitions, - &mut new_default_source, - ); - let default_source_id = font.add_source(new_default_source); - font.set_default_source_id(default_source_id.clone()); - move_glyph_layers_to_source(&mut font, default_ufo_source_id, default_source_id)?; - - let mut ufo_cache: HashMap = HashMap::new(); - for (idx, ds_source) in sources.iter().enumerate().skip(1) { - let ufo_path = ds_dir.join(&ds_source.filename); - let ufo_str = ufo_path - .to_str() - .ok_or_else(|| DesignspaceError::InvalidPathUtf8 { - path: ufo_path.clone(), - })? - .to_string(); - - let source_font = match ufo_cache.get(&ufo_str) { - Some(f) => f, - None => { - let loaded = - ufo_reader - .load(&ufo_str) - .map_err(|source| DesignspaceError::LoadUfo { - path: ufo_path.clone(), - details: source.to_string(), - })?; - ufo_cache.insert(ufo_str.clone(), loaded); - ufo_cache.get(&ufo_str).unwrap() - } - }; - - let source_source_id = match &ds_source.layer { - Some(layer_name) => find_source_by_external_layer_name(source_font, layer_name) - .ok_or_else(|| DesignspaceError::MissingLayer { - layer: layer_name.clone(), - filename: ds_source.filename.clone(), - })?, - None => source_font - .default_source_id() - .ok_or(DesignspaceError::NoSources)?, - }; - - let name = axisless_source_name( - ds_source, - source_font.metadata().style_name.as_deref(), - &source_names, - idx, - ); - source_names.insert(name.clone()); - let mut source = Source::with_filename(name, Location::new(), ds_source.filename.clone()); - source.set_layer_name(ds_source.layer.clone()); - let imported_source = source_font - .sources() - .iter() - .find(|source| source.id() == source_source_id) - .ok_or(DesignspaceError::NoSources)?; - copy_source_metrics( - source_font.metric_definitions(), - imported_source, - font.metric_definitions(), - &mut source, - ); - let source_id = font.add_source(source); - - for source_glyph in source_font.glyphs() { - if let Some(source_layer) = source_glyph.layer_for_source(source_source_id.clone()) { - if let Some(glyph_id) = font.glyph_id_by_name(source_glyph.name()) { - font.insert_glyph_layer( - glyph_id, - clone_layer_with_remapped_components( - source_layer, - &font, - LayerId::new(), - source_id.clone(), - )?, - )?; - } - } - } - } - - remove_glyph_layers_without_source(&mut font)?; - Ok(font) -} - pub(super) fn axisless_source_name( source: &AxislessSource, ufo_style_name: Option<&str>, @@ -862,105 +504,6 @@ fn design_values_equal(left: f64, right: f64) -> bool { (left - right).abs() <= f64::from(f32::EPSILON) * scale * 4.0 } -fn source_layer_id( - font: &Font, - source: &norad::designspace::Source, -) -> DesignspaceResult { - match &source.layer { - Some(layer_name) => find_source_by_external_layer_name(font, layer_name).ok_or_else(|| { - DesignspaceError::MissingLayer { - layer: layer_name.clone(), - filename: source.filename.clone(), - } - }), - None => font.default_source_id().ok_or(DesignspaceError::NoSources), - } -} - -fn find_source_by_external_layer_name(font: &Font, name: &str) -> Option { - font.sources() - .iter() - .find(|source| !source.is_master() && source.name() == name) - .map(Source::id) -} - -fn move_glyph_layers_to_source( - font: &mut Font, - from_source_id: SourceId, - to_source_id: SourceId, -) -> DesignspaceResult<()> { - let layer_moves: Vec<_> = font - .glyphs() - .filter_map(|glyph| { - glyph.layer_for_source(from_source_id.clone()).map(|layer| { - ( - glyph.id(), - layer.id(), - layer.clone_with_identity(LayerId::new(), to_source_id.clone()), - ) - }) - }) - .collect(); - - for (glyph_id, old_layer_id, layer) in layer_moves { - font.remove_glyph_layer(old_layer_id)?; - font.insert_glyph_layer(glyph_id, layer)?; - } - - Ok(()) -} - -fn clone_layer_with_remapped_components( - layer: &GlyphLayer, - font: &Font, - layer_id: LayerId, - source_id: SourceId, -) -> DesignspaceResult { - let mut cloned = layer.clone_with_identity(layer_id, source_id); - cloned.clear_components(); - - for component in layer.components_iter() { - let base_glyph_id = font - .glyph_id_by_name(component.base_glyph_name().as_str()) - .ok_or_else(|| DesignspaceError::LoadUfo { - path: std::path::PathBuf::from(component.base_glyph_name().as_str()), - details: format!( - "component base glyph {:?} does not exist in default font", - component.base_glyph_name() - ), - })?; - cloned.add_component(Component::with_id( - component.id(), - base_glyph_id, - component.base_glyph_name().clone(), - *component.transform(), - )); - } - - Ok(cloned) -} - -fn remove_glyph_layers_without_source(font: &mut Font) -> DesignspaceResult<()> { - let source_ids: Vec<_> = font.sources().iter().map(Source::id).collect(); - let orphan_layer_ids: Vec<_> = font - .glyphs() - .flat_map(|glyph| { - glyph - .layers() - .values() - .filter(|layer| !source_ids.contains(&layer.source_id())) - .map(|layer| layer.id()) - .collect::>() - }) - .collect(); - - for layer_id in orphan_layer_ids { - font.remove_glyph_layer(layer_id)?; - } - - Ok(()) -} - /// Derive (minimum, maximum) for an axis from norad's parsed designspace. /// /// Designspace axis edge cases handled: diff --git a/crates/shift-backends/src/errors.rs b/crates/shift-backends/src/errors.rs index f4de719c..8e4a8320 100644 --- a/crates/shift-backends/src/errors.rs +++ b/crates/shift-backends/src/errors.rs @@ -20,6 +20,9 @@ pub enum BackendError { #[error("unsupported font format for writing: {extension}")] UnsupportedWriteFormat { extension: String }, + #[error("streaming import is not supported for {} fonts", format.name())] + StreamingUnsupported { format: FontFormat }, + #[error("font format adaptor is not registered: {}", format.name())] MissingAdaptor { format: FontFormat }, diff --git a/crates/shift-backends/src/font_loader.rs b/crates/shift-backends/src/font_loader.rs index 7f8e8ab8..6410875f 100644 --- a/crates/shift-backends/src/font_loader.rs +++ b/crates/shift-backends/src/font_loader.rs @@ -8,15 +8,19 @@ use crate::designspace::{DesignspaceReader, DesignspaceWriter}; use crate::errors::{BackendError, BackendResult, FormatBackendError, FormatBackendResult}; use crate::format::FontFormat; use crate::glyphs::GlyphsReader; -use crate::import::FontImport; +use crate::import::{FontImport, GlyphStream}; use crate::traits::{FontReader, FontWriter}; use crate::ufo::{UfoReader, UfoWriter}; use crate::binary::BytesFontAdaptor; -pub trait FontAdaptor { +pub(crate) trait FontAdaptor { fn read_font(&self, path: &str) -> FormatBackendResult; fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()>; + + fn stream(&self, _path: &str) -> FormatBackendResult)>> { + Ok(None) + } } struct UfoFontAdaptor; @@ -44,6 +48,11 @@ impl FontAdaptor for UfoFontAdaptor { fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()> { UfoWriter::new().save(font, path) } + + fn stream(&self, path: &str) -> FormatBackendResult)>> { + let (header, stream) = crate::ufo::stream_font(path)?; + Ok(Some((header, Box::new(stream)))) + } } impl FontAdaptor for GlyphsFontAdaptor { @@ -64,6 +73,11 @@ impl FontAdaptor for DesignspaceFontAdaptor { fn write_font(&self, font: &Font, path: &str) -> FormatBackendResult<()> { DesignspaceWriter::new().save(font, path) } + + fn stream(&self, path: &str) -> FormatBackendResult)>> { + let (header, stream) = crate::designspace::stream_font(path)?; + Ok(Some((header, Box::new(stream)))) + } } pub struct FontLoader { @@ -102,6 +116,24 @@ fn extension_from_path(path: &Path) -> BackendResult<&str> { }) } +struct ResolvedPath<'a> { + format: FontFormat, + path: &'a str, + path_buf: std::path::PathBuf, + extension: &'a str, +} + +fn resolve(path: &str) -> BackendResult> { + let parsed = Path::new(path); + let extension = extension_from_path(parsed)?; + Ok(ResolvedPath { + format: format_from_extension(extension)?, + path, + path_buf: parsed.to_path_buf(), + extension, + }) +} + impl FontLoader { pub fn new() -> Self { let mut adaptors: HashMap> = HashMap::new(); @@ -120,76 +152,71 @@ impl FontLoader { } pub fn stream_font(&self, path: &str) -> BackendResult { - let path = Path::new(path); - let ext = extension_from_path(path)?; - let format = format_from_extension(ext)?; - let path_buf = path.to_path_buf(); - let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { - path: path_buf.clone(), - })?; - - match format { - FontFormat::Ttf | FontFormat::Otf => { - let (header, stream) = crate::binary::stream_font_file(path) - .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; - Ok(FontImport::new(header, stream, format, path_buf)) - } - FontFormat::Ufo => { - let (header, stream) = crate::ufo::stream_font(path) - .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; - Ok(FontImport::new(header, stream, format, path_buf)) - } - FontFormat::Designspace => { - let (header, stream) = crate::designspace::stream_font(path) - .map_err(|source| BackendError::load(format, path_buf.clone(), source))?; - Ok(FontImport::new(header, stream, format, path_buf)) - } - _ => Err(BackendError::MissingAdaptor { format }), - } + let resolved = resolve(path)?; + let adaptor = self + .adaptors + .get(&resolved.format) + .ok_or(BackendError::MissingAdaptor { + format: resolved.format, + })?; + let streamed = adaptor + .stream(resolved.path) + .map_err(|source| { + BackendError::load(resolved.format, resolved.path_buf.clone(), source) + })? + .ok_or(BackendError::StreamingUnsupported { + format: resolved.format, + })?; + Ok(FontImport::new( + streamed.0, + streamed.1, + resolved.format, + resolved.path_buf, + )) } pub fn read_font(&self, path: &str) -> BackendResult { - let path = Path::new(path); - let ext = extension_from_path(path)?; - let format = format_from_extension(ext)?; - let path_buf = path.to_path_buf(); + let resolved = resolve(path)?; let adaptor = self .adaptors - .get(&format) - .ok_or(BackendError::MissingAdaptor { format })?; - let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { - path: path_buf.clone(), + .get(&resolved.format) + .ok_or(BackendError::MissingAdaptor { + format: resolved.format, + })?; + let streamed = adaptor.stream(resolved.path).map_err(|source| { + BackendError::load(resolved.format, resolved.path_buf.clone(), source) })?; + if let Some((header, stream)) = streamed { + return FontImport::new(header, stream, resolved.format, resolved.path_buf) + .collect_font(); + } + adaptor - .read_font(path) - .map_err(|source| BackendError::load(format, path_buf, source)) + .read_font(resolved.path) + .map_err(|source| BackendError::load(resolved.format, resolved.path_buf, source)) } pub fn write_font(&self, font: &Font, path: &str) -> BackendResult<()> { - let path = Path::new(path); - let ext = extension_from_path(path)?; - let format = format_from_extension(ext)?; + let resolved = resolve(path)?; - match format { + match resolved.format { FontFormat::Ufo | FontFormat::Designspace | FontFormat::Shift => {} _ => { return Err(BackendError::UnsupportedWriteFormat { - extension: ext.to_string(), + extension: resolved.extension.to_string(), }) } } - let path_buf = path.to_path_buf(); let adaptor = self .adaptors - .get(&format) - .ok_or(BackendError::MissingAdaptor { format })?; - let path = path.to_str().ok_or_else(|| BackendError::InvalidPathUtf8 { - path: path_buf.clone(), - })?; + .get(&resolved.format) + .ok_or(BackendError::MissingAdaptor { + format: resolved.format, + })?; adaptor - .write_font(font, path) - .map_err(|source| BackendError::save(format, path_buf, source)) + .write_font(font, resolved.path) + .map_err(|source| BackendError::save(resolved.format, resolved.path_buf, source)) } } diff --git a/crates/shift-backends/src/import.rs b/crates/shift-backends/src/import.rs index 2e0ed0b1..46eca5d4 100644 --- a/crates/shift-backends/src/import.rs +++ b/crates/shift-backends/src/import.rs @@ -45,6 +45,24 @@ pub(crate) trait GlyphStream: Send { fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult>; } +pub(crate) fn collect_streamed_font( + mut header: Font, + stream: &mut dyn GlyphStream, +) -> FormatBackendResult { + let expected_glyph_count = stream.glyph_count(); + loop { + let glyphs = stream.next_batch(ImportBatchLimit::default())?; + if glyphs.is_empty() { + break; + } + for glyph in glyphs { + header.insert_glyph(glyph)?; + } + } + debug_assert_eq!(header.glyph_count(), expected_glyph_count); + Ok(header) +} + /// A bounded foreign-font import. /// /// The header contains top-level authored state but no glyphs. Glyph geometry @@ -59,13 +77,13 @@ pub struct FontImport { impl FontImport { pub(crate) fn new( header: Font, - stream: impl GlyphStream + 'static, + stream: Box, format: FontFormat, path: PathBuf, ) -> Self { Self { header, - stream: Box::new(stream), + stream, format, path, } @@ -88,4 +106,18 @@ impl FontImport { .next_batch(limit) .map_err(|source| BackendError::load(self.format, self.path.clone(), source)) } + + /// Drains the same bounded stream used by native import into one eager + /// font for compatibility callers. Streaming semantics are therefore the + /// only parsing semantics for formats that implement them. + pub fn collect_font(self) -> BackendResult { + let Self { + header, + mut stream, + format, + path, + } = self; + collect_streamed_font(header, stream.as_mut()) + .map_err(|source| BackendError::load(format, path, source)) + } } diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index 4630759b..faee1a72 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -1,11 +1,12 @@ use crate::errors::{FormatBackendError, FormatBackendResult}; +use crate::import::collect_streamed_font; use crate::metrics::set_metric_position; use crate::traits::FontReader; use norad::{Font as NoradFont, Line}; use shift_font::{ Anchor, Component, Contour, FeatureData, Font, Glyph, GlyphId, GlyphLayer, Guideline, KerningData, KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, - Source, SourceId, Transform, + SourceId, Transform, }; use std::collections::HashMap; use std::path::Path; @@ -45,7 +46,6 @@ pub(crate) const MAPPED_FONTINFO_KEYS: &[&str] = &[ ]; struct PendingComponent { - layer_id: LayerId, base_glyph_name: String, matrix: Transform, } @@ -81,9 +81,8 @@ impl UfoReader { shift_contour } - fn convert_component(layer_id: LayerId, component: &norad::Component) -> PendingComponent { + fn convert_component(component: &norad::Component) -> PendingComponent { PendingComponent { - layer_id, base_glyph_name: component.base.to_string(), matrix: Transform { xx: component.transform.x_scale, @@ -191,7 +190,6 @@ impl UfoReader { source_id: SourceId, ) -> (Glyph, Vec) { let mut glyph_layer = GlyphLayer::with_width(layer_id, source_id, norad_glyph.width); - let layer_id = glyph_layer.id(); let mut pending_components = Vec::new(); if norad_glyph.height != 0.0 { glyph_layer.set_height(Some(norad_glyph.height)); @@ -202,7 +200,7 @@ impl UfoReader { } for component in &norad_glyph.components { - pending_components.push(Self::convert_component(layer_id.clone(), component)); + pending_components.push(Self::convert_component(component)); } for anchor in &norad_glyph.anchors { @@ -416,35 +414,6 @@ impl UfoReader { } Ok(font) } - - fn resolve_components( - font: &mut Font, - pending_components: Vec, - ) -> FormatBackendResult<()> { - for pending in pending_components { - let base_glyph_id = - font.glyph_id_by_name(&pending.base_glyph_name) - .ok_or_else(|| { - FormatBackendError::Ufo(format!( - "component base glyph {:?} does not exist", - pending.base_glyph_name - )) - })?; - let layer = font.layer_mut(pending.layer_id.clone()).ok_or_else(|| { - FormatBackendError::Ufo(format!( - "component target layer {} does not exist", - pending.layer_id - )) - })?; - layer.add_component(Component::with_matrix( - base_glyph_id, - pending.base_glyph_name, - &pending.matrix, - )); - } - - Ok(()) - } } impl Default for UfoReader { @@ -455,49 +424,7 @@ impl Default for UfoReader { impl FontReader for UfoReader { fn load(&self, path: &str) -> FormatBackendResult { - let norad_font = - NoradFont::load(path).map_err(|e| FormatBackendError::Ufo(e.to_string()))?; - let ufo_path = Path::new(path); - - let mut font = Self::convert_header(&norad_font, ufo_path)?; - let default_source_id = font - .default_source_id() - .expect("new font should have a default source"); - - let norad_default_layer_name = norad_font.layers.default_layer().name().clone(); - let mut pending_components = Vec::new(); - for layer in norad_font.layers.iter() { - let source_id = if layer.name() == &norad_default_layer_name { - default_source_id.clone() - } else { - // Non-default UFO layers are carried as layer-only sources: - // they are not designspace masters and must not gain - // `` entries if the font is saved as a designspace. - font.add_source(Source::layer(layer.name().to_string())) - }; - - if let Some(source) = font.source_mut(source_id.clone()) { - source.set_color(layer.color.as_ref().map(|color| color.to_rgba_string())); - if !layer.lib.is_empty() { - *source.lib_mut() = Self::convert_lib(&layer.lib); - } - } - - for norad_glyph in layer.iter() { - let (glyph, glyph_components) = - Self::convert_glyph_layer(norad_glyph, LayerId::new(), source_id.clone()); - pending_components.extend(glyph_components); - - if let Some(glyph_id) = font.glyph_id_by_name(glyph.name()) { - for layer_data in glyph.layers().values() { - font.insert_glyph_layer(glyph_id.clone(), layer_data.as_ref().clone())?; - } - } else { - font.insert_glyph(glyph)?; - } - } - } - Self::resolve_components(&mut font, pending_components)?; - Ok(font) + let (header, mut stream) = super::stream_font(path)?; + collect_streamed_font(header, &mut stream) } } diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index 41845cf4..dc82f3b4 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -53,25 +53,14 @@ fn load_font(path: &Path) -> Font { } fn stream_font(path: &Path) -> Font { - let mut import = FontLoader::new() + let import = FontLoader::new() .stream_font(path.to_str().unwrap()) .unwrap_or_else(|error| panic!("failed to stream {}: {error}", path.display())); assert_eq!(import.header().glyph_count(), 0); let directory = import.directory(); let expected_count = import.glyph_count(); assert_eq!(directory.len(), expected_count); - let mut font = import.header().clone(); - loop { - let glyphs = import - .next_batch(shift_backends::ImportBatchLimit::new(8, 64)) - .unwrap(); - if glyphs.is_empty() { - break; - } - for glyph in glyphs { - font.insert_glyph(glyph).unwrap(); - } - } + let font = import.collect_font().unwrap(); assert_eq!(font.glyph_count(), expected_count); for entry in directory { assert_eq!( @@ -220,7 +209,7 @@ fn streams_binary_ufo_and_designspace_without_eager_glyphs() { for path in [binary_path, mutatorsans_ufo_path()] { let eager = load_font(&path); let streamed = stream_font(&path); - assert!(streamed.glyph_count() >= eager.glyph_count()); + assert_eq!(streamed.glyph_count(), eager.glyph_count()); let glyph_a = streamed .glyphs_by_unicode(65) .next() diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 42a0adde..df61230a 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -734,38 +734,36 @@ impl FontWorkspace { let import_path_str = import_path .to_str() .ok_or_else(|| WorkspaceError::InvalidPathUtf8(import_path.to_path_buf()))?; - let extension = import_path - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or_default() - .to_ascii_lowercase(); - - if matches!(extension.as_str(), "ttf" | "otf" | "ufo" | "designspace") { - let import = FontLoader::new().stream_font(import_path_str)?; - let mut store = ShiftStore::open_for_import(store_path)?; - let mut writer = store.font_stream_writer(import.header())?; - write_import_batches(import, &mut writer, ImportBatchLimit::default())?; - writer.finish()?; - store.finish_import()?; - store.set_workspace_state(WorkspaceState::imported(import_path, None))?; - let font = store.load_font_directory()?; - let residency = LayerResidency::with_unloaded( - font.glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()), - ); - - return Ok(Self { - font, - source: WorkspaceSource::Imported { - original_path: import_path.to_path_buf(), - }, - store, - ledger: Ledger::default(), - residency, - }); + let loader = FontLoader::new(); + match loader.stream_font(import_path_str) { + Ok(import) => { + let mut store = ShiftStore::open_for_import(store_path)?; + let mut writer = store.font_stream_writer(import.header())?; + write_import_batches(import, &mut writer, ImportBatchLimit::default())?; + writer.finish()?; + store.finish_import()?; + store.set_workspace_state(WorkspaceState::imported(import_path, None))?; + let font = store.load_font_directory()?; + let residency = LayerResidency::with_unloaded( + font.glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()), + ); + + return Ok(Self { + font, + source: WorkspaceSource::Imported { + original_path: import_path.to_path_buf(), + }, + store, + ledger: Ledger::default(), + residency, + }); + } + Err(shift_backends::BackendError::StreamingUnsupported { .. }) => {} + Err(error) => return Err(error.into()), } - let font = FontLoader::new().read_font(import_path_str)?; + let font = loader.read_font(import_path_str)?; let mut store = ShiftStore::open(store_path)?; store.set_font_info(font_info_from_font(&font))?; store.replace_font_state(&font)?; From 2242d58bdc8850dfabadf2e850e6f2260365bfb3 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 07:50:10 +0000 Subject: [PATCH 07/29] Share streamed glyph and store write paths --- .../shift-backends/src/designspace/import.rs | 148 +++--------------- crates/shift-backends/src/import.rs | 105 ++++++++++++- crates/shift-backends/src/ufo/import.rs | 112 +------------ crates/shift-store/src/change_set.rs | 83 +++++----- crates/shift-store/src/lib.rs | 1 + crates/shift-store/src/packed_layer.rs | 136 +++++++--------- crates/shift-store/src/stream_writer.rs | 49 +++--- crates/shift-store/src/write_mode.rs | 5 + 8 files changed, 255 insertions(+), 384 deletions(-) create mode 100644 crates/shift-store/src/write_mode.rs diff --git a/crates/shift-backends/src/designspace/import.rs b/crates/shift-backends/src/designspace/import.rs index 538e6e84..2ac18820 100644 --- a/crates/shift-backends/src/designspace/import.rs +++ b/crates/shift-backends/src/designspace/import.rs @@ -6,13 +6,13 @@ use std::{ use norad::designspace::DesignSpaceDocument; use rayon::prelude::*; -use shift_font::{Axis, AxisId, Font, Glyph, GlyphId, LayerId, Location, Source, SourceId}; +use shift_font::{Axis, AxisId, Font, GlyphId, LayerId, Location, Source, SourceId}; use crate::{ errors::{FormatBackendError, FormatBackendResult}, - import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, + import::{GlifGlyph, GlifGlyphStream, GlifLayer}, metrics::copy_source_metrics, - ufo::{load_header, read_glyph_paths, UfoReader}, + ufo::{load_header, read_glyph_paths}, }; use super::{ @@ -30,33 +30,11 @@ struct SourceDirectory { glyphs: BTreeMap, } -#[derive(Clone)] -struct SourceGlyph { - source_id: SourceId, - layer_id: LayerId, - path: PathBuf, -} - -#[derive(Clone)] -struct GlyphRecord { - id: GlyphId, - name: String, - layers: Vec, -} - -pub(crate) struct DesignspaceGlyphStream { - glyph_ids: HashMap, - glyphs: Vec, - next_glyph: usize, -} - -pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, DesignspaceGlyphStream)> { +pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, GlifGlyphStream)> { stream_designspace(Path::new(path)).map_err(FormatBackendError::from) } -fn stream_designspace( - designspace_path: &Path, -) -> DesignspaceResult<(Font, DesignspaceGlyphStream)> { +fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifGlyphStream)> { let designspace_dir = designspace_path .parent() @@ -161,15 +139,8 @@ fn stream_designspace( directories.push(SourceDirectory { source_id, glyphs }); } - let (glyph_ids, glyphs) = build_glyph_directory(&directories, 0); - Ok(( - header, - DesignspaceGlyphStream { - glyph_ids, - glyphs, - next_glyph: 0, - }, - )) + let (glyph_ids, glyphs) = build_glyph_directory(&directories); + Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) } fn stream_axisless_designspace( @@ -177,7 +148,7 @@ fn stream_axisless_designspace( designspace_dir: &Path, xml: &str, original_error: &str, -) -> DesignspaceResult<(Font, DesignspaceGlyphStream)> { +) -> DesignspaceResult<(Font, GlifGlyphStream)> { let sources = parse_axisless_sources(xml).map_err(|fallback_error| { DesignspaceError::LoadDesignspace { path: designspace_path.to_path_buf(), @@ -271,54 +242,8 @@ fn stream_axisless_designspace( directories.push(SourceDirectory { source_id, glyphs }); } - let (glyph_ids, glyphs) = build_glyph_directory(&directories, 0); - Ok(( - header, - DesignspaceGlyphStream { - glyph_ids, - glyphs, - next_glyph: 0, - }, - )) -} - -impl GlyphStream for DesignspaceGlyphStream { - fn directory(&self) -> Vec { - self.glyphs - .iter() - .map(|glyph| GlyphDirectoryEntry { - glyph_id: glyph.id.clone(), - name: glyph.name.clone().into(), - }) - .collect() - } - - fn glyph_count(&self) -> usize { - self.glyphs.len() - } - - fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { - if self.next_glyph == self.glyphs.len() { - return Ok(Vec::new()); - } - let mut end = self.next_glyph; - let mut layer_count = 0; - while end < self.glyphs.len() && end - self.next_glyph < limit.max_glyphs() { - let next_layers = self.glyphs[end].layers.len(); - if end > self.next_glyph && layer_count + next_layers > limit.max_layers() { - break; - } - layer_count += next_layers; - end += 1; - } - let glyph_ids = &self.glyph_ids; - let glyphs = self.glyphs[self.next_glyph..end] - .par_iter() - .map(|record| load_glyph(record, glyph_ids)) - .collect::>>()?; - self.next_glyph = end; - Ok(glyphs) - } + let (glyph_ids, glyphs) = build_glyph_directory(&directories); + Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) } fn add_axes( @@ -361,13 +286,8 @@ fn add_axes( fn build_glyph_directory( directories: &[SourceDirectory], - default_index: usize, -) -> (HashMap, Vec) { - let names = directories[default_index] - .glyphs - .keys() - .cloned() - .collect::>(); +) -> (HashMap, Vec) { + let names = directories[0].glyphs.keys().cloned().collect::>(); let glyph_ids = names .iter() .map(|name| (name.clone(), GlyphId::new())) @@ -376,26 +296,23 @@ fn build_glyph_directory( .into_iter() .map(|name| { let mut layers = Vec::new(); - if let Some(path) = directories[default_index].glyphs.get(&name) { - layers.push(SourceGlyph { - source_id: directories[default_index].source_id.clone(), + if let Some(path) = directories[0].glyphs.get(&name) { + layers.push(GlifLayer { + source_id: directories[0].source_id.clone(), layer_id: LayerId::new(), path: path.clone(), }); } - for (index, directory) in directories.iter().enumerate() { - if index == default_index { - continue; - } + for directory in directories.iter().skip(1) { if let Some(path) = directory.glyphs.get(&name) { - layers.push(SourceGlyph { + layers.push(GlifLayer { source_id: directory.source_id.clone(), layer_id: LayerId::new(), path: path.clone(), }); } } - GlyphRecord { + GlifGlyph { id: glyph_ids[&name].clone(), name, layers, @@ -404,32 +321,3 @@ fn build_glyph_directory( .collect(); (glyph_ids, glyphs) } - -fn load_glyph( - record: &GlyphRecord, - glyph_ids: &HashMap, -) -> FormatBackendResult { - let mut glyph = Glyph::with_id(record.id.clone(), record.name.clone()); - for (index, source) in record.layers.iter().enumerate() { - let norad_glyph = norad::Glyph::load(&source.path).map_err(|error| { - FormatBackendError::Ufo(format!( - "failed to read glyph {:?} from {}: {error}", - record.name, - source.path.display() - )) - })?; - if index == 0 { - glyph.set_unicodes(norad_glyph.codepoints.iter().map(u32::from).collect()); - if !norad_glyph.lib.is_empty() { - *glyph.lib_mut() = UfoReader::convert_lib(&norad_glyph.lib); - } - } - glyph.set_layer(UfoReader::convert_stream_layer( - &norad_glyph, - source.layer_id.clone(), - source.source_id.clone(), - glyph_ids, - )?); - } - Ok(glyph) -} diff --git a/crates/shift-backends/src/import.rs b/crates/shift-backends/src/import.rs index 46eca5d4..74573ee9 100644 --- a/crates/shift-backends/src/import.rs +++ b/crates/shift-backends/src/import.rs @@ -1,6 +1,7 @@ -use std::path::PathBuf; +use std::{collections::HashMap, path::PathBuf}; -use shift_font::{Font, Glyph, GlyphId, GlyphName}; +use rayon::prelude::*; +use shift_font::{Font, Glyph, GlyphId, GlyphName, LayerId, SourceId}; use crate::{BackendError, BackendResult, FontFormat, FormatBackendResult}; @@ -39,6 +40,106 @@ impl Default for ImportBatchLimit { } } +#[derive(Clone)] +pub(crate) struct GlifLayer { + pub(crate) source_id: SourceId, + pub(crate) layer_id: LayerId, + pub(crate) path: PathBuf, +} + +#[derive(Clone)] +pub(crate) struct GlifGlyph { + pub(crate) id: GlyphId, + pub(crate) name: String, + pub(crate) layers: Vec, +} + +pub(crate) struct GlifGlyphStream { + glyph_ids: HashMap, + glyphs: Vec, + next_glyph: usize, +} + +impl GlifGlyphStream { + pub(crate) fn new(glyph_ids: HashMap, glyphs: Vec) -> Self { + Self { + glyph_ids, + glyphs, + next_glyph: 0, + } + } +} + +impl GlyphStream for GlifGlyphStream { + fn directory(&self) -> Vec { + self.glyphs + .iter() + .map(|glyph| GlyphDirectoryEntry { + glyph_id: glyph.id.clone(), + name: glyph.name.clone().into(), + }) + .collect() + } + + fn glyph_count(&self) -> usize { + self.glyphs.len() + } + + fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { + if self.next_glyph == self.glyphs.len() { + return Ok(Vec::new()); + } + + let mut end = self.next_glyph; + let mut layer_count = 0; + while end < self.glyphs.len() && end - self.next_glyph < limit.max_glyphs() { + let next_layers = self.glyphs[end].layers.len(); + if end > self.next_glyph && layer_count + next_layers > limit.max_layers() { + break; + } + layer_count += next_layers; + end += 1; + } + let glyph_ids = &self.glyph_ids; + let glyphs = self.glyphs[self.next_glyph..end] + .par_iter() + .map(|record| load_glif_glyph(record, glyph_ids)) + .collect::>>()?; + self.next_glyph = end; + Ok(glyphs) + } +} + +fn load_glif_glyph( + record: &GlifGlyph, + glyph_ids: &HashMap, +) -> FormatBackendResult { + let mut glyph = Glyph::with_id(record.id.clone(), record.name.clone()); + for (index, layer_record) in record.layers.iter().enumerate() { + let norad_glyph = norad::Glyph::load(&layer_record.path).map_err(|error| { + crate::FormatBackendError::Ufo(format!( + "failed to read glyph {:?} from {}: {error}", + record.name, + layer_record.path.display() + )) + })?; + if index == 0 { + glyph.set_unicodes(norad_glyph.codepoints.iter().map(u32::from).collect()); + if !norad_glyph.lib.is_empty() { + *glyph.lib_mut() = crate::ufo::UfoReader::convert_lib(&norad_glyph.lib); + } + } + let layer = crate::ufo::UfoReader::convert_stream_layer( + &norad_glyph, + layer_record.layer_id.clone(), + layer_record.source_id.clone(), + glyph_ids, + )?; + glyph.set_layer(layer); + } + Ok(glyph) +} + pub(crate) trait GlyphStream: Send { fn directory(&self) -> Vec; fn glyph_count(&self) -> usize; diff --git a/crates/shift-backends/src/ufo/import.rs b/crates/shift-backends/src/ufo/import.rs index 8a9bb5ad..2da61246 100644 --- a/crates/shift-backends/src/ufo/import.rs +++ b/crates/shift-backends/src/ufo/import.rs @@ -4,12 +4,11 @@ use std::{ }; use norad::{DataRequest, Font as NoradFont}; -use rayon::prelude::*; -use shift_font::{Font, Glyph, GlyphId, LayerId, Source, SourceId}; +use shift_font::{Font, GlyphId, LayerId, Source, SourceId}; use crate::{ errors::{FormatBackendError, FormatBackendResult}, - import::{GlyphDirectoryEntry, GlyphStream, ImportBatchLimit}, + import::{GlifGlyph, GlifGlyphStream, GlifLayer}, }; use super::UfoReader; @@ -19,27 +18,7 @@ struct LayerDirectory { glyphs: BTreeMap, } -#[derive(Clone)] -struct LayerGlyph { - source_id: SourceId, - layer_id: LayerId, - path: PathBuf, -} - -#[derive(Clone)] -struct GlyphRecord { - id: GlyphId, - name: String, - layers: Vec, -} - -pub(crate) struct UfoGlyphStream { - glyph_ids: HashMap, - glyphs: Vec, - next_glyph: usize, -} - -pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, UfoGlyphStream)> { +pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, GlifGlyphStream)> { let ufo_path = Path::new(path); let mut header = load_header(ufo_path)?; let default_source_id = header @@ -48,54 +27,7 @@ pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, UfoGlyphStre let layers = load_layer_directories(ufo_path, &mut header, default_source_id)?; let (glyph_ids, glyphs) = build_glyph_directory(layers); - Ok(( - header, - UfoGlyphStream { - glyph_ids, - glyphs, - next_glyph: 0, - }, - )) -} - -impl GlyphStream for UfoGlyphStream { - fn directory(&self) -> Vec { - self.glyphs - .iter() - .map(|glyph| GlyphDirectoryEntry { - glyph_id: glyph.id.clone(), - name: glyph.name.clone().into(), - }) - .collect() - } - - fn glyph_count(&self) -> usize { - self.glyphs.len() - } - - fn next_batch(&mut self, limit: ImportBatchLimit) -> FormatBackendResult> { - if self.next_glyph == self.glyphs.len() { - return Ok(Vec::new()); - } - - let mut end = self.next_glyph; - let mut layer_count = 0; - while end < self.glyphs.len() && end - self.next_glyph < limit.max_glyphs() { - let next_layers = self.glyphs[end].layers.len(); - if end > self.next_glyph && layer_count + next_layers > limit.max_layers() { - break; - } - layer_count += next_layers; - end += 1; - } - let glyph_ids = &self.glyph_ids; - let glyphs = self.glyphs[self.next_glyph..end] - .par_iter() - .map(|record| load_glyph(record, glyph_ids)) - .collect::>>()?; - self.next_glyph = end; - Ok(glyphs) - } + Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) } fn load_layer_directories( @@ -226,7 +158,7 @@ fn read_layer_info(layer_path: &Path) -> FormatBackendResult<(Option, pl fn build_glyph_directory( layers: Vec, -) -> (HashMap, Vec) { +) -> (HashMap, Vec) { let mut ordered_names = Vec::new(); let mut seen = HashSet::new(); for layer in &layers { @@ -246,14 +178,14 @@ fn build_glyph_directory( let layer_records = layers .iter() .filter_map(|layer| { - layer.glyphs.get(&name).map(|path| LayerGlyph { + layer.glyphs.get(&name).map(|path| GlifLayer { source_id: layer.source_id.clone(), layer_id: LayerId::new(), path: path.clone(), }) }) .collect(); - GlyphRecord { + GlifGlyph { id: glyph_ids[&name].clone(), name, layers: layer_records, @@ -262,33 +194,3 @@ fn build_glyph_directory( .collect(); (glyph_ids, glyphs) } - -fn load_glyph( - record: &GlyphRecord, - glyph_ids: &HashMap, -) -> FormatBackendResult { - let mut glyph = Glyph::with_id(record.id.clone(), record.name.clone()); - for (index, layer_record) in record.layers.iter().enumerate() { - let norad_glyph = norad::Glyph::load(&layer_record.path).map_err(|error| { - FormatBackendError::Ufo(format!( - "failed to read glyph {:?} from {}: {error}", - record.name, - layer_record.path.display() - )) - })?; - if index == 0 { - glyph.set_unicodes(norad_glyph.codepoints.iter().map(u32::from).collect()); - if !norad_glyph.lib.is_empty() { - *glyph.lib_mut() = UfoReader::convert_lib(&norad_glyph.lib); - } - } - let layer = UfoReader::convert_stream_layer( - &norad_glyph, - layer_record.layer_id.clone(), - layer_record.source_id.clone(), - glyph_ids, - )?; - glyph.set_layer(layer); - } - Ok(glyph) -} diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index e5242135..947ab372 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -8,6 +8,7 @@ use crate::{ packed_layer::{create_empty_layer_in_tx, rewrite_layer_in_tx, write_layer_in_tx}, source::SourceKind, workspace_state::mark_workspace_dirty_in_tx, + write_mode::WriteMode, }; impl ShiftStore { @@ -157,7 +158,7 @@ pub(crate) fn write_glyph_in_tx( glyph: &font::Glyph, order_index: i64, ) -> Result<(), StoreError> { - write_glyph_directory_in_tx(tx, glyph, order_index)?; + write_glyph_directory_in_tx(tx, glyph, order_index, WriteMode::Upsert)?; for layer in glyph.layers().values().map(|layer| layer.as_ref()) { write_layer_in_tx(tx, &glyph.id(), Some(glyph.glyph_name()), layer)?; @@ -165,48 +166,54 @@ pub(crate) fn write_glyph_in_tx( Ok(()) } -pub(crate) fn insert_glyph_directory_in_tx( - tx: &Transaction<'_>, - glyph: &font::Glyph, - order_index: i64, -) -> Result<(), StoreError> { - tx.prepare_cached("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)")? - .execute(params![ - glyph.id().to_string(), - glyph.glyph_name().as_str(), - order_index - ])?; - for (unicode_order, unicode) in glyph.unicodes().iter().enumerate() { - tx.prepare_cached( - "INSERT INTO glyph_unicodes (glyph_id, unicode, order_index) VALUES (?1, ?2, ?3)", - )? - .execute(params![ - glyph.id().to_string(), - i64::from(*unicode), - unicode_order as i64 - ])?; - } - for (key, value) in glyph.lib().iter() { - tx.prepare_cached("INSERT INTO glyph_lib (glyph_id, key, value_json) VALUES (?1, ?2, ?3)")? - .execute(params![glyph.id().to_string(), key, lib_value_json(value)?])?; - } - Ok(()) -} - pub(crate) fn write_glyph_directory_in_tx( tx: &Transaction<'_>, glyph: &font::Glyph, order_index: i64, + mode: WriteMode, ) -> Result<(), StoreError> { - upsert_glyph(tx, &glyph.id(), glyph.glyph_name(), order_index)?; - replace_glyph_unicodes(tx, &glyph.id(), glyph.unicodes())?; - replace_lib_data( - tx, - "glyph_lib", - "glyph_id", - Some(&glyph.id().to_string()), - glyph.lib(), - ) + match mode { + WriteMode::Insert => { + tx.prepare_cached("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)")? + .execute(params![ + glyph.id().to_string(), + glyph.glyph_name().as_str(), + order_index + ])?; + for (unicode_order, unicode) in glyph.unicodes().iter().enumerate() { + tx.prepare_cached( + "INSERT INTO glyph_unicodes (glyph_id, unicode, order_index) VALUES (?1, ?2, ?3)", + )? + .execute(params![ + glyph.id().to_string(), + i64::from(*unicode), + unicode_order as i64 + ])?; + } + for (key, value) in glyph.lib().iter() { + tx.prepare_cached( + "INSERT INTO glyph_lib (glyph_id, key, value_json) VALUES (?1, ?2, ?3)", + )? + .execute(params![ + glyph.id().to_string(), + key, + lib_value_json(value)? + ])?; + } + Ok(()) + } + WriteMode::Upsert => { + upsert_glyph(tx, &glyph.id(), glyph.glyph_name(), order_index)?; + replace_glyph_unicodes(tx, &glyph.id(), glyph.unicodes())?; + replace_lib_data( + tx, + "glyph_lib", + "glyph_id", + Some(&glyph.id().to_string()), + glyph.lib(), + ) + } + } } fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), StoreError> { diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 4631cadd..c7ab1737 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -14,6 +14,7 @@ mod store; mod stream_writer; mod types; mod workspace_state; +mod write_mode; pub use component::{GlyphComponentRecord, NewGlyphComponent}; pub use error::StoreError; diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index 0333acee..da03c718 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -3,7 +3,9 @@ use std::collections::{HashMap, HashSet, VecDeque}; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use shift_font as font; -use crate::{ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx}; +use crate::{ + ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, +}; pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; @@ -443,25 +445,37 @@ pub(crate) fn write_layer_in_tx( layer: &font::GlyphLayer, ) -> Result<(), StoreError> { let packed = font::pack_glyph_layer(layer)?; - write_packed_layer_in_tx(tx, glyph_id, name, layer, &packed) + store_packed_layer_in_tx(tx, glyph_id, name, layer, &packed, WriteMode::Upsert) } -pub(crate) fn insert_packed_layer_in_tx( +pub(crate) fn store_packed_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, name: Option<&font::GlyphName>, layer: &font::GlyphLayer, packed: &shift_glyph_codec::PackedGlyphLayer, + mode: WriteMode, ) -> Result<(), StoreError> { let bytes = packed.as_bytes(); - let byte_length = i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { - bytes: bytes.len() as u64, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - })?; - tx.prepare_cached( - "INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - )? - .execute(params![ + let byte_length = encoded_len(bytes)?; + let layer_sql = match mode { + WriteMode::Insert => { + "INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + } + WriteMode::Upsert => { + " + INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(id) DO UPDATE SET + glyph_id = excluded.glyph_id, + source_id = excluded.source_id, + name = excluded.name, + width = excluded.width, + height = excluded.height + " + } + }; + tx.prepare_cached(layer_sql)?.execute(params![ layer.id().to_string(), glyph_id.to_string(), layer.source_id().to_string(), @@ -469,81 +483,36 @@ pub(crate) fn insert_packed_layer_in_tx( layer.width(), layer.height(), ])?; - tx.prepare_cached( - "INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, ?3, ?4)", - )? - .execute(params![ + + let payload_sql = match mode { + WriteMode::Insert => { + "INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, ?3, ?4)" + } + WriteMode::Upsert => { + " + INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(layer_id) DO UPDATE SET + format = excluded.format, + payload = excluded.payload, + byte_length = excluded.byte_length + " + } + }; + tx.prepare_cached(payload_sql)?.execute(params![ layer.id().to_string(), GLYPH_LAYER_FORMAT, bytes, byte_length, ])?; - for (order_index, component) in layer.components_iter().enumerate() { - tx.prepare_cached( - "INSERT INTO glyph_components (id, layer_id, base_glyph_id, order_index) VALUES (?1, ?2, ?3, ?4)", - )? - .execute(params![ - component.id().to_string(), - layer.id().to_string(), - component.base_glyph_id().to_string(), - order_index as i64, - ])?; - } - Ok(()) + write_component_index(tx, layer, mode) } -pub(crate) fn write_packed_layer_in_tx( - tx: &Transaction<'_>, - glyph_id: &font::GlyphId, - name: Option<&font::GlyphName>, - layer: &font::GlyphLayer, - packed: &shift_glyph_codec::PackedGlyphLayer, -) -> Result<(), StoreError> { - let bytes = packed.as_bytes(); - - tx.prepare_cached( - " - INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(id) DO UPDATE SET - glyph_id = excluded.glyph_id, - source_id = excluded.source_id, - name = excluded.name, - width = excluded.width, - height = excluded.height - ", - )? - .execute(params![ - layer.id().to_string(), - glyph_id.to_string(), - layer.source_id().to_string(), - name.map(font::GlyphName::as_str), - layer.width(), - layer.height(), - ])?; - tx.prepare_cached( - " - INSERT INTO glyph_layer_payloads ( - layer_id, format, payload, byte_length - ) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(layer_id) DO UPDATE SET - format = excluded.format, - payload = excluded.payload, - byte_length = excluded.byte_length - ", - )? - .execute(params![ - layer.id().to_string(), - GLYPH_LAYER_FORMAT, - bytes, - i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { - bytes: bytes.len() as u64, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - })?, - ])?; - replace_component_index(tx, layer)?; - Ok(()) +fn encoded_len(bytes: &[u8]) -> Result { + i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes: bytes.len() as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }) } pub(crate) fn rewrite_layer_in_tx( @@ -571,12 +540,15 @@ pub(crate) fn create_empty_layer_in_tx( write_layer_in_tx(tx, glyph_id, name, &layer) } -fn replace_component_index( +fn write_component_index( tx: &Transaction<'_>, layer: &font::GlyphLayer, + mode: WriteMode, ) -> Result<(), StoreError> { - tx.prepare_cached("DELETE FROM glyph_components WHERE layer_id = ?1")? - .execute([layer.id().to_string()])?; + if mode == WriteMode::Upsert { + tx.prepare_cached("DELETE FROM glyph_components WHERE layer_id = ?1")? + .execute([layer.id().to_string()])?; + } for (order_index, component) in layer.components_iter().enumerate() { tx.prepare_cached( " diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index 7ff8a90d..82ef5b1c 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -4,10 +4,10 @@ use shift_font as font; use crate::{ ShiftStore, StoreError, - change_set::{ - insert_glyph_directory_in_tx, replace_font_header_in_tx, write_glyph_directory_in_tx, - }, + change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, + packed_layer::store_packed_layer_in_tx, workspace_state::mark_workspace_dirty_in_tx, + write_mode::WriteMode, }; /// Bounded transactional sink for format-specific importers. @@ -27,6 +27,15 @@ enum StreamMode { Replacement, } +impl StreamMode { + fn write_mode(self) -> WriteMode { + match self { + Self::Update => WriteMode::Upsert, + Self::Replacement => WriteMode::Insert, + } + } +} + impl ShiftStore { /// Starts a stream against the existing font directory. pub fn layer_stream_writer(&mut self) -> Result, StoreError> { @@ -77,15 +86,9 @@ impl LayerStreamWriter<'_> { }) .collect::, font::PackedLayerError>>()?; + let write_mode = self.mode.write_mode(); for (glyph, packed_layers) in glyphs.iter().zip(packed_layers) { - match self.mode { - StreamMode::Update => { - write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order)?; - } - StreamMode::Replacement => { - insert_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order)?; - } - } + write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, write_mode)?; for (layer_id, packed) in packed_layers { let layer = glyph @@ -95,22 +98,14 @@ impl LayerStreamWriter<'_> { kind: "glyph layer", id: layer_id.to_string(), })?; - match self.mode { - StreamMode::Update => crate::packed_layer::write_packed_layer_in_tx( - &self.tx, - &glyph.id(), - Some(glyph.glyph_name()), - layer, - &packed, - )?, - StreamMode::Replacement => crate::packed_layer::insert_packed_layer_in_tx( - &self.tx, - &glyph.id(), - Some(glyph.glyph_name()), - layer, - &packed, - )?, - } + store_packed_layer_in_tx( + &self.tx, + &glyph.id(), + Some(glyph.glyph_name()), + layer, + &packed, + write_mode, + )?; } self.next_glyph_order += 1; } diff --git a/crates/shift-store/src/write_mode.rs b/crates/shift-store/src/write_mode.rs new file mode 100644 index 00000000..729e042f --- /dev/null +++ b/crates/shift-store/src/write_mode.rs @@ -0,0 +1,5 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum WriteMode { + Insert, + Upsert, +} From 90d60080263e98b39e203446685ba3e885c8b51f Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 08:02:52 +0000 Subject: [PATCH 08/29] Consolidate canonical layer store paths --- crates/shift-store/src/change_set.rs | 34 ++-- crates/shift-store/src/component.rs | 106 ----------- crates/shift-store/src/font_state.rs | 62 ++++++- crates/shift-store/src/layer.rs | 96 ---------- crates/shift-store/src/lib.rs | 8 +- crates/shift-store/src/outline.rs | 116 ------------ crates/shift-store/src/packed_layer.rs | 240 ++++++++++++++++--------- crates/shift-store/src/types.rs | 26 --- crates/shift-store/tests/store_test.rs | 239 +++++++++++++----------- 9 files changed, 365 insertions(+), 562 deletions(-) delete mode 100644 crates/shift-store/src/component.rs delete mode 100644 crates/shift-store/src/layer.rs delete mode 100644 crates/shift-store/src/outline.rs diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index 947ab372..207cb032 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -36,8 +36,10 @@ impl ShiftStore { let mut touched_layer_ids = HashSet::new(); for change in &change_set.changes { - apply_change(&tx, change)?; - if let Some(layer_id) = changed_layer_id(change) { + if post_font.is_none() || !post_font_supersedes_incremental_layer_write(change) { + apply_change(&tx, change)?; + } + if let Some(layer_id) = change.layer_id() { touched_layer_ids.insert(layer_id.clone()); } } @@ -466,21 +468,19 @@ fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), S } } -fn changed_layer_id(change: &font::FontChange) -> Option<&font::LayerId> { - match change { - font::FontChange::GlyphLayerCreated(change) => Some(&change.layer_id), - font::FontChange::GlyphLayerDeleted(change) => Some(&change.layer_id), - font::FontChange::LayerMetricsChanged(change) => Some(&change.layer_id), - font::FontChange::ContourAdded(change) => Some(&change.layer_id), - font::FontChange::ContourOpenClosedChanged(change) => Some(&change.layer_id), - font::FontChange::PointsAdded(change) => Some(&change.layer_id), - font::FontChange::PointsDeleted(change) => Some(&change.layer_id), - font::FontChange::PointSmoothChanged(change) => Some(&change.layer_id), - font::FontChange::PointPositionsChanged(change) => Some(&change.layer_id), - font::FontChange::AnchorPositionsChanged(change) => Some(&change.layer_id), - font::FontChange::LayerGeometryReplaced(change) => Some(&change.layer_id), - _ => None, - } +fn post_font_supersedes_incremental_layer_write(change: &font::FontChange) -> bool { + matches!( + change, + font::FontChange::LayerMetricsChanged(_) + | font::FontChange::ContourAdded(_) + | font::FontChange::ContourOpenClosedChanged(_) + | font::FontChange::PointsAdded(_) + | font::FontChange::PointsDeleted(_) + | font::FontChange::PointSmoothChanged(_) + | font::FontChange::PointPositionsChanged(_) + | font::FontChange::AnchorPositionsChanged(_) + | font::FontChange::LayerGeometryReplaced(_) + ) } fn update_packed_layer( diff --git a/crates/shift-store/src/component.rs b/crates/shift-store/src/component.rs deleted file mode 100644 index 9111d1f3..00000000 --- a/crates/shift-store/src/component.rs +++ /dev/null @@ -1,106 +0,0 @@ -use rusqlite::OptionalExtension; - -use crate::{ComponentId, GlyphId, LayerId, ShiftStore, StoreError}; - -pub struct NewGlyphComponent { - pub id: ComponentId, - pub layer_id: LayerId, - pub base_glyph_id: GlyphId, - pub base_glyph_name: String, - pub transform: shift_font::DecomposedTransform, - pub order_index: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct GlyphComponentRecord { - pub id: ComponentId, - pub layer_id: LayerId, - pub base_glyph_id: GlyphId, - pub base_glyph_name: String, - pub transform: shift_font::DecomposedTransform, - pub order_index: i64, -} - -impl ShiftStore { - pub fn create_glyph_component( - &mut self, - component: NewGlyphComponent, - ) -> Result<(), StoreError> { - let layer_id = shift_font::LayerId::from_raw(component.layer_id.as_str()); - let mut layer = - self.load_glyph_layer(&layer_id)? - .ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: layer_id.to_string(), - })?; - if component.order_index != layer.components().len() as i64 { - return Err(StoreError::InvalidWorkspaceState(format!( - "component order {} does not append to layer {}", - component.order_index, layer_id - ))); - } - layer.add_component(shift_font::Component::with_id( - shift_font::ComponentId::from_raw(component.id.as_str()), - shift_font::GlyphId::from_raw(component.base_glyph_id.as_str()), - component.base_glyph_name, - component.transform, - )); - self.replace_glyph_layer(&layer) - } - - pub fn get_glyph_component( - &self, - id: &ComponentId, - ) -> Result, StoreError> { - let layer_id = self - .conn - .query_row( - "SELECT layer_id FROM glyph_components WHERE id = ?1", - [id.as_str()], - |row| row.get::<_, String>(0), - ) - .optional()?; - let Some(layer_id) = layer_id else { - return Ok(None); - }; - let layer_id = shift_font::LayerId::from_raw(layer_id); - let Some(layer) = self.load_glyph_layer(&layer_id)? else { - return Ok(None); - }; - Ok(layer - .components_iter() - .enumerate() - .find(|(_, component)| component.id().as_str() == id.as_str()) - .map(|(order_index, component)| component_record(&layer, component, order_index))) - } - - pub fn list_glyph_components_for_layer( - &self, - layer_id: &LayerId, - ) -> Result, StoreError> { - let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); - let Some(layer) = self.load_glyph_layer(&layer_id)? else { - return Ok(Vec::new()); - }; - Ok(layer - .components_iter() - .enumerate() - .map(|(order_index, component)| component_record(&layer, component, order_index)) - .collect()) - } -} - -fn component_record( - layer: &shift_font::GlyphLayer, - component: &shift_font::Component, - order_index: usize, -) -> GlyphComponentRecord { - GlyphComponentRecord { - id: ComponentId::new(component.id().to_string()), - layer_id: LayerId::new(layer.id().to_string()), - base_glyph_id: GlyphId::new(component.base_glyph_id().to_string()), - base_glyph_name: component.base_glyph_name().to_string(), - transform: *component.transform(), - order_index: order_index as i64, - } -} diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index d64c826a..cf11f1ac 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -365,9 +365,10 @@ fn load_glyphs( let layer_rows = { let mut stmt = conn.prepare( " - SELECT id, glyph_id, source_id, width, height - FROM glyph_layers - ORDER BY glyph_id, source_id, id + SELECT l.id, l.glyph_id, l.source_id, l.width, l.height, p.byte_length + FROM glyph_layers AS l + LEFT JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + ORDER BY l.glyph_id, l.source_id, l.id ", )?; stmt.query_map([], |row| { @@ -377,19 +378,48 @@ fn load_glyphs( font::SourceId::from_raw(row.get::<_, String>(2)?), row.get::<_, f64>(3)?, row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, )) })? .collect::, _>>()? }; + let mut loaded_layers = HashMap::new(); + if include_layer_payloads { + let mut batch = Vec::new(); + let mut batch_bytes = 0_u64; + for (layer_id, _, _, _, _, byte_length) in &layer_rows { + let byte_length = byte_length.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer payload", + id: layer_id.to_string(), + })?; + let byte_length = + u64::try_from(byte_length).map_err(|_| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_string(), + detail: format!("negative payload byte length {byte_length}"), + })?; + if !batch.is_empty() + && (batch.len() == crate::MAX_LAYER_READ_BATCH_COUNT + || batch_bytes.saturating_add(byte_length) + > crate::MAX_LAYER_READ_BATCH_PAYLOAD_BYTES) + { + load_layer_batch(conn, &mut batch, &mut loaded_layers)?; + batch_bytes = 0; + } + batch.push(layer_id.clone()); + batch_bytes = batch_bytes.saturating_add(byte_length); + } + load_layer_batch(conn, &mut batch, &mut loaded_layers)?; + } + let mut layers: HashMap> = HashMap::new(); - for (layer_id, glyph_id, source_id, width, height) in layer_rows { + for (layer_id, glyph_id, source_id, width, height, _) in layer_rows { let layer = if include_layer_payloads { - crate::packed_layer::load_glyph_layer_from_conn(conn, &layer_id)?.ok_or_else(|| { - StoreError::MissingEntity { + loaded_layers + .remove(&layer_id) + .ok_or_else(|| StoreError::MissingEntity { kind: "glyph layer payload", id: layer_id.to_string(), - } - })? + })? } else { let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); layer.set_height(height); @@ -418,6 +448,22 @@ fn load_glyphs( Ok(glyphs) } +fn load_layer_batch( + conn: &rusqlite::Connection, + batch: &mut Vec, + loaded: &mut HashMap, +) -> Result<(), StoreError> { + if batch.is_empty() { + return Ok(()); + } + + let layer_ids = std::mem::take(batch); + for layer in crate::packed_layer::load_glyph_layers_from_conn(conn, &layer_ids)? { + loaded.insert(layer.id(), layer); + } + Ok(()) +} + fn load_font_guidelines(conn: &rusqlite::Connection) -> Result, StoreError> { let mut stmt = conn.prepare( " diff --git a/crates/shift-store/src/layer.rs b/crates/shift-store/src/layer.rs deleted file mode 100644 index 33b76882..00000000 --- a/crates/shift-store/src/layer.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::{GlyphId, LayerId, ShiftStore, SourceId, StoreError}; - -pub struct NewGlyphLayer { - pub id: LayerId, - pub glyph_id: GlyphId, - pub source_id: SourceId, - pub name: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct GlyphLayerRecord { - pub id: LayerId, - pub glyph_id: GlyphId, - pub source_id: SourceId, - pub name: Option, - pub width: f64, - pub height: Option, -} - -impl ShiftStore { - pub fn create_glyph_layer(&mut self, layer: NewGlyphLayer) -> Result<(), StoreError> { - let tx = self.conn.transaction()?; - crate::packed_layer::create_empty_layer_in_tx( - &tx, - &shift_font::GlyphId::from_raw(layer.glyph_id.as_str()), - layer - .name - .as_deref() - .map(shift_font::GlyphName::from) - .as_ref(), - shift_font::LayerId::from_raw(layer.id.as_str()), - shift_font::SourceId::from_raw(layer.source_id.as_str()), - 0.0, - None, - )?; - tx.commit()?; - Ok(()) - } - - pub fn get_glyph_layer(&self, id: &LayerId) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT - id, - glyph_id, - source_id, - name, - width, - height - FROM glyph_layers - WHERE id = ?1 - ", - )?; - - match stmt.query_row([id.as_str()], map_glyph_layer_row) { - Ok(layer) => Ok(Some(layer)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(err) => Err(err.into()), - } - } - - pub fn list_glyph_layers_for_glyph( - &self, - glyph_id: &GlyphId, - ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT - id, - glyph_id, - source_id, - name, - width, - height - FROM glyph_layers - WHERE glyph_id = ?1 - ORDER BY source_id, id - ", - )?; - - let rows = stmt.query_map([glyph_id.as_str()], map_glyph_layer_row)?; - rows.collect::, _>>() - .map_err(StoreError::from) - } -} - -fn map_glyph_layer_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(GlyphLayerRecord { - id: LayerId::new(row.get::<_, String>(0)?), - glyph_id: GlyphId::new(row.get::<_, String>(1)?), - source_id: SourceId::new(row.get::<_, String>(2)?), - name: row.get(3)?, - width: row.get(4)?, - height: row.get(5)?, - }) -} diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index c7ab1737..76e7865c 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -1,12 +1,9 @@ mod change_set; -mod component; mod connection; mod error; mod font; mod font_state; mod glyph; -mod layer; -mod outline; mod packed_layer; mod schema; mod source; @@ -16,12 +13,9 @@ mod types; mod workspace_state; mod write_mode; -pub use component::{GlyphComponentRecord, NewGlyphComponent}; pub use error::StoreError; pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; -pub use layer::{GlyphLayerRecord, NewGlyphLayer}; -pub use outline::{AnchorRecord, ContourRecord, PointRecord}; pub use packed_layer::{ GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT, MAX_LAYER_READ_BATCH_PAYLOAD_BYTES, @@ -29,7 +23,7 @@ pub use packed_layer::{ pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; pub use stream_writer::LayerStreamWriter; -pub use types::{AxisId, ComponentId, GlyphId, LayerId, RevisionId, SourceId}; +pub use types::{AxisId, GlyphId, RevisionId, SourceId}; pub use workspace_state::{ Evidence, FileIdentity, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState, }; diff --git a/crates/shift-store/src/outline.rs b/crates/shift-store/src/outline.rs deleted file mode 100644 index d3000873..00000000 --- a/crates/shift-store/src/outline.rs +++ /dev/null @@ -1,116 +0,0 @@ -use crate::{LayerId, ShiftStore, StoreError}; - -#[derive(Clone, Debug, PartialEq)] -pub struct ContourRecord { - pub id: String, - pub layer_id: LayerId, - pub closed: bool, - pub order_index: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PointRecord { - pub id: String, - pub contour_id: String, - pub order_index: i64, - pub x: f64, - pub y: f64, - pub point_type: String, - pub smooth: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct AnchorRecord { - pub id: String, - pub layer_id: LayerId, - pub name: Option, - pub x: f64, - pub y: f64, - pub order_index: i64, -} - -impl ShiftStore { - pub fn list_contours_for_layer( - &self, - layer_id: &LayerId, - ) -> Result, StoreError> { - let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); - let Some(layer) = self.load_glyph_layer(&layer_id)? else { - return Ok(Vec::new()); - }; - Ok(layer - .contours_iter() - .enumerate() - .map(|(order_index, contour)| ContourRecord { - id: contour.id().to_string(), - layer_id: LayerId::new(layer.id().to_string()), - closed: contour.is_closed(), - order_index: order_index as i64, - }) - .collect()) - } - - /// Compatibility lookup for callers that only retain a contour id. - /// Interactive acquisition should address a layer directly. - pub fn list_points_for_contour( - &self, - contour_id: &str, - ) -> Result, StoreError> { - for entry in self.list_glyph_layer_directory()? { - let Some(layer) = self.load_glyph_layer(&entry.layer_id)? else { - continue; - }; - let Some(contour) = layer - .contours_iter() - .find(|contour| contour.id().as_str() == contour_id) - else { - continue; - }; - return Ok(contour - .points() - .iter() - .enumerate() - .map(|(order_index, point)| PointRecord { - id: point.id().to_string(), - contour_id: contour_id.to_owned(), - order_index: order_index as i64, - x: point.x(), - y: point.y(), - point_type: point_type_name(point.point_type()).to_owned(), - smooth: point.is_smooth(), - }) - .collect()); - } - Ok(Vec::new()) - } - - pub fn list_anchors_for_layer( - &self, - layer_id: &LayerId, - ) -> Result, StoreError> { - let layer_id = shift_font::LayerId::from_raw(layer_id.as_str()); - let Some(layer) = self.load_glyph_layer(&layer_id)? else { - return Ok(Vec::new()); - }; - Ok(layer - .anchors_iter() - .enumerate() - .map(|(order_index, anchor)| AnchorRecord { - id: anchor.id().to_string(), - layer_id: LayerId::new(layer.id().to_string()), - name: anchor.name().map(str::to_owned), - x: anchor.x(), - y: anchor.y(), - order_index: order_index as i64, - }) - .collect()) - } -} - -fn point_type_name(point_type: shift_font::PointType) -> &'static str { - match point_type { - shift_font::PointType::OnCurve => "onCurve", - shift_font::PointType::OffCurve => "offCurve", - shift_font::PointType::QCurve => "qCurve", - } -} diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index da03c718..1a384da5 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -195,52 +195,73 @@ impl ShiftStore { } } +struct DirectoryFacts { + layer_id: String, + source_id: String, + width: f64, + height: Option, + format: String, + declared_len: i64, + actual_len: i64, +} + +impl DirectoryFacts { + fn validate(&self) -> Result { + if self.format != GLYPH_LAYER_FORMAT { + return Err(StoreError::UnsupportedLayerFormat(self.format.clone())); + } + if self.declared_len != self.actual_len { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "declared byte length {} != actual {}", + self.declared_len, self.actual_len + ), + }); + } + if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&self.actual_len) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(self.actual_len).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + Ok(self.actual_len as u64) + } +} + +fn map_directory_facts_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(DirectoryFacts { + layer_id: row.get(0)?, + source_id: row.get(1)?, + width: row.get(2)?, + height: row.get(3)?, + format: row.get(4)?, + declared_len: row.get(5)?, + actual_len: row.get(6)?, + }) +} + pub(crate) fn load_glyph_layer_from_conn( conn: &Connection, layer_id: &font::LayerId, ) -> Result, StoreError> { let mut directory_stmt = conn.prepare_cached( " - SELECT l.glyph_id, l.source_id, l.width, l.height, + SELECT l.id, l.source_id, l.width, l.height, p.format, p.byte_length, length(p.payload) FROM glyph_layers AS l JOIN glyph_layer_payloads AS p ON p.layer_id = l.id WHERE l.id = ?1 ", )?; - let row = directory_stmt - .query_row([layer_id.to_string()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, f64>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, i64>(5)?, - row.get::<_, i64>(6)?, - )) - }) + let facts = directory_stmt + .query_row([layer_id.to_string()], map_directory_facts_row) .optional()?; drop(directory_stmt); - let Some((_glyph_id, source_id, width, height, format, declared_len, actual_len)) = row else { + let Some(facts) = facts else { return Ok(None); }; - - if format != GLYPH_LAYER_FORMAT { - return Err(StoreError::UnsupportedLayerFormat(format)); - } - if declared_len != actual_len { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: layer_id.to_string(), - detail: format!("declared byte length {declared_len} != actual {actual_len}"), - }); - } - if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&actual_len) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(actual_len).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } + facts.validate()?; // The length gate is deliberately a separate query so SQLite cannot // allocate an unbounded Vec before the store applies its limit. @@ -255,7 +276,11 @@ pub(crate) fn load_glyph_layer_from_conn( )?; let payload: Vec = payload_stmt .query_row( - params![layer_id.to_string(), actual_len, MAX_LAYER_PAYLOAD_BYTES], + params![ + layer_id.to_string(), + facts.actual_len, + MAX_LAYER_PAYLOAD_BYTES + ], |row| row.get(0), ) .optional()? @@ -264,12 +289,18 @@ pub(crate) fn load_glyph_layer_from_conn( detail: "payload changed during bounded read".to_string(), })?; let layer = font::unpack_glyph_layer(&payload)?; - validate_directory_facts(layer_id, &source_id, width, height, &layer)?; + validate_directory_facts( + layer_id, + &facts.source_id, + facts.width, + facts.height, + &layer, + )?; validate_component_index(conn, &layer)?; Ok(Some(layer)) } -fn load_glyph_layers_from_conn( +pub(crate) fn load_glyph_layers_from_conn( conn: &Connection, layer_ids: &[font::LayerId], ) -> Result, StoreError> { @@ -300,40 +331,17 @@ fn load_glyph_layers_from_conn( ORDER BY l.id " ); - let mut directory_stmt = conn.prepare_cached(&directory_sql)?; - let directory_rows = - directory_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, f64>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, String>(4)?, - row.get::<_, i64>(5)?, - row.get::<_, i64>(6)?, - )) - })?; + let mut directory_stmt = conn.prepare(&directory_sql)?; + let directory_rows = directory_stmt.query_map( + rusqlite::params_from_iter(keys.iter()), + map_directory_facts_row, + )?; let mut facts = HashMap::with_capacity(keys.len()); let mut batch_payload_bytes = 0_u64; for row in directory_rows { - let (layer_id, source_id, width, height, format, declared_len, actual_len) = row?; - if format != GLYPH_LAYER_FORMAT { - return Err(StoreError::UnsupportedLayerFormat(format)); - } - if declared_len != actual_len { - return Err(StoreError::LayerDirectoryMismatch { - layer_id, - detail: format!("declared byte length {declared_len} != actual {actual_len}"), - }); - } - if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&actual_len) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(actual_len).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } - batch_payload_bytes = batch_payload_bytes.saturating_add(actual_len as u64); - facts.insert(layer_id, (source_id, width, height, actual_len)); + let facts_row = row?; + batch_payload_bytes = batch_payload_bytes.saturating_add(facts_row.validate()?); + facts.insert(facts_row.layer_id.clone(), facts_row); } drop(directory_stmt); @@ -357,30 +365,36 @@ fn load_glyph_layers_from_conn( SELECT layer_id, payload, length(payload) FROM glyph_layer_payloads WHERE layer_id IN ({placeholders}) - AND length(payload) <= {MAX_LAYER_PAYLOAD_BYTES} + AND length(payload) <= ? ORDER BY layer_id " ); - let mut payload_stmt = conn.prepare_cached(&payload_sql)?; - let payload_rows = payload_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, i64>(2)?, - )) - })?; + let mut payload_params = keys + .iter() + .cloned() + .map(rusqlite::types::Value::Text) + .collect::>(); + payload_params.push(rusqlite::types::Value::Integer(MAX_LAYER_PAYLOAD_BYTES)); + let mut payload_stmt = conn.prepare(&payload_sql)?; + let payload_rows = + payload_stmt.query_map(rusqlite::params_from_iter(payload_params), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + })?; let mut layers = Vec::with_capacity(keys.len()); let mut loaded = HashSet::with_capacity(keys.len()); for row in payload_rows { let (layer_id, payload, actual_len) = row?; - let (source_id, width, height, expected_len) = - facts - .get(&layer_id) - .ok_or_else(|| StoreError::LayerDirectoryMismatch { - layer_id: layer_id.clone(), - detail: "payload has no directory row".to_string(), - })?; - if actual_len != *expected_len || payload.len() as i64 != *expected_len { + let facts = facts + .get(&layer_id) + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.clone(), + detail: "payload has no directory row".to_string(), + })?; + if actual_len != facts.actual_len || payload.len() as i64 != facts.actual_len { return Err(StoreError::LayerDirectoryMismatch { layer_id, detail: "payload changed during bounded batch read".to_string(), @@ -388,7 +402,13 @@ fn load_glyph_layers_from_conn( } let layer = font::unpack_glyph_layer(&payload)?; let expected_id = font::LayerId::from_raw(layer_id.clone()); - validate_directory_facts(&expected_id, source_id, *width, *height, &layer)?; + validate_directory_facts( + &expected_id, + &facts.source_id, + facts.width, + facts.height, + &layer, + )?; loaded.insert(layer_id); layers.push(layer); } @@ -411,7 +431,7 @@ fn load_glyph_layers_from_conn( ORDER BY layer_id, order_index, id " ); - let mut component_stmt = conn.prepare_cached(&component_sql)?; + let mut component_stmt = conn.prepare(&component_sql)?; let component_rows = component_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { Ok(( @@ -804,6 +824,31 @@ mod tests { ); } + #[test] + fn directory_retains_layer_when_payload_row_is_missing() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "DELETE FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + + let directory = store.load_font_directory().unwrap(); + + assert!(directory.layer(layer_id.clone()).is_some()); + assert!(store.load_font_state().is_err()); + } + #[test] fn stale_reference_index_is_rejected() { let (store, font) = populated_store(); @@ -916,6 +961,35 @@ mod tests { assert_eq!(store.workspace_state().unwrap().unwrap().revision, 1); } + #[test] + fn post_font_change_set_rewrites_touched_layer_without_decoding_old_payload() { + let (mut store, mut post_font) = populated_store(); + let mut layer = post_font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .next() + .unwrap() + .as_ref() + .clone(); + layer.set_width(layer.width() + 41.0); + post_font.replace_glyph_layers(vec![layer.clone()]).unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads SET payload = X'00', byte_length = 1 WHERE layer_id = ?1", + [layer.id().to_string()], + ) + .unwrap(); + let changes = + font::FontChangeSet::new(vec![font::FontChange::layer_metrics_changed(&layer)]); + + store + .apply_change_set_with_font(&changes, &post_font) + .unwrap(); + + assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); + } + #[test] fn abandoned_transaction_preserves_last_committed_layer() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index b9791716..daf006ed 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -1,16 +1,3 @@ -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct ComponentId(String); - -impl ComponentId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct AxisId(String); @@ -50,19 +37,6 @@ impl GlyphId { } } -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub struct LayerId(String); - -impl LayerId { - pub fn new(id: impl Into) -> Self { - Self(id.into()) - } - - pub fn as_str(&self) -> &str { - &self.0 - } -} - #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct RevisionId(i64); diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 1cf68a9f..419a80a7 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -1,8 +1,7 @@ use shift_font::{FontMetadata, test_support::sample_font}; use shift_store::{ - AxisId, ComponentId, Evidence, FileIdentity, FontInfo, GlyphId, LayerId, NewAxis, NewGlyph, - NewGlyphComponent, NewGlyphLayer, NewSource, ShiftStore, SourceId, SourceIdentitySnapshot, - SourceKind, WorkspaceState, + AxisId, Evidence, FileIdentity, FontInfo, GlyphId, NewAxis, NewGlyph, NewSource, ShiftStore, + SourceId, SourceIdentitySnapshot, SourceKind, WorkspaceState, }; #[test] @@ -283,27 +282,38 @@ fn creates_and_reads_glyph_layer() { let source_id = create_regular_source(&mut store); let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); - let layer = store - .get_glyph_layer(&layer_id) - .expect("glyph layer query should succeed") + let directory = store + .list_glyph_layer_directory() + .expect("glyph layer directory query should succeed"); + let layer = directory + .iter() + .find(|entry| entry.layer_id == layer_id) .expect("glyph layer should exist"); - assert_eq!(layer.id, layer_id); - assert_eq!(layer.glyph_id, glyph_id); - assert_eq!(layer.source_id, source_id); - assert_eq!(layer.name.as_deref(), Some("Regular")); + assert_eq!(layer.glyph_id.as_str(), glyph_id.as_str()); + assert_eq!(layer.source_id.as_str(), source_id.as_str()); + assert_eq!( + layer.name.as_ref().map(|name| name.as_str()), + Some("Regular") + ); } #[test] fn glyph_layer_requires_existing_glyph_and_source() { let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); - let result = store.create_glyph_layer(NewGlyphLayer { - id: LayerId::new("layer-A-regular"), - glyph_id: GlyphId::new("glyph-missing"), - source_id: SourceId::new("source-missing"), - name: Some("Regular".to_string()), - }); + let layer = shift_font::GlyphLayer::with_width( + shift_font::LayerId::from_raw("layer-A-regular"), + shift_font::SourceId::from_raw("source-missing"), + 0.0, + ); + let result = store.apply_change_set(&shift_font::FontChangeSet::new(vec![ + shift_font::FontChange::glyph_layer_created( + shift_font::GlyphId::from_raw("glyph-missing"), + Some(shift_font::GlyphName::from("Regular")), + &layer, + ), + ])); assert!(result.is_err()); } @@ -316,13 +326,15 @@ fn lists_glyph_layers_for_glyph() { let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); let layers = store - .list_glyph_layers_for_glyph(&glyph_id) - .expect("glyph layers query should succeed"); + .list_glyph_layer_directory() + .expect("glyph layers query should succeed") + .into_iter() + .filter(|entry| entry.glyph_id.as_str() == glyph_id.as_str()) + .collect::>(); assert_eq!(layers.len(), 1); - assert_eq!(layers[0].id, layer_id); - assert_eq!(layers[0].glyph_id, glyph_id); - assert_eq!(layers[0].source_id, source_id); + assert_eq!(layers[0].layer_id, layer_id); + assert_eq!(layers[0].source_id.as_str(), source_id.as_str()); } #[test] @@ -334,29 +346,35 @@ fn creates_and_reads_glyph_component() { let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); - let component = store - .get_glyph_component(&component_id) - .expect("glyph component query should succeed") + let layer = store + .load_glyph_layer(&layer_id) + .expect("glyph layer query should succeed") + .expect("glyph layer should exist"); + let component = layer + .components_iter() + .next() .expect("glyph component should exist"); - assert_eq!(component.id, component_id); - assert_eq!(component.layer_id, layer_id); - assert_eq!(component.base_glyph_id, base_glyph_id); - assert_eq!(component.order_index, 0); + assert_eq!(component.id(), component_id); + assert_eq!(component.base_glyph_id().as_str(), base_glyph_id.as_str()); } #[test] -fn glyph_component_requires_existing_layer_and_base_glyph() { +fn glyph_component_replacement_requires_existing_layer() { let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); + let mut layer = shift_font::GlyphLayer::with_width( + shift_font::LayerId::from_raw("layer-missing"), + shift_font::SourceId::from_raw("source-missing"), + 0.0, + ); + layer.add_component(shift_font::Component::with_id( + shift_font::ComponentId::from_raw("component-A-missing"), + shift_font::GlyphId::from_raw("glyph-missing"), + "Missing", + shift_font::DecomposedTransform::identity(), + )); - let result = store.create_glyph_component(NewGlyphComponent { - id: ComponentId::new("component-A-B"), - layer_id: LayerId::new("layer-missing"), - base_glyph_id: GlyphId::new("glyph-missing"), - base_glyph_name: "Missing".to_string(), - transform: shift_font::DecomposedTransform::identity(), - order_index: 0, - }); + let result = store.replace_glyph_layer(&layer); assert!(result.is_err()); } @@ -370,15 +388,16 @@ fn lists_glyph_components_for_layer() { let layer_id = create_default_glyph_layer(&mut store, &glyph_id, &source_id); let component_id = create_default_component(&mut store, &layer_id, &base_glyph_id); - let components = store - .list_glyph_components_for_layer(&layer_id) - .expect("glyph components query should succeed"); + let layer = store + .load_glyph_layer(&layer_id) + .expect("glyph layer query should succeed") + .expect("glyph layer should exist"); + let components = layer.components(); + let (_, component) = components.iter().next().expect("component should exist"); assert_eq!(components.len(), 1); - assert_eq!(components[0].id, component_id); - assert_eq!(components[0].layer_id, layer_id); - assert_eq!(components[0].base_glyph_id, base_glyph_id); - assert_eq!(components[0].order_index, 0); + assert_eq!(component.id(), component_id); + assert_eq!(component.base_glyph_id().as_str(), base_glyph_id.as_str()); } #[test] @@ -438,8 +457,11 @@ fn applies_glyph_delete_change_set_and_cascades_layers() { .get_glyph(&GlyphId::new(glyph_id.to_string())) .expect("glyph query should succeed"); let layers = store - .list_glyph_layers_for_glyph(&GlyphId::new(glyph_id.to_string())) - .expect("layer query should succeed"); + .list_glyph_layer_directory() + .expect("layer query should succeed") + .into_iter() + .filter(|entry| entry.glyph_id == glyph_id) + .collect::>(); let unicodes = store .list_glyph_unicodes(&GlyphId::new(glyph_id.to_string())) .expect("unicode query should succeed"); @@ -484,23 +506,20 @@ fn applies_layer_metrics_and_contour_point_changes() { .expect("change set should apply"); let layer = store - .get_glyph_layer(&LayerId::new(layer.id().to_string())) + .load_glyph_layer(&layer.id()) .expect("layer query should succeed") .expect("layer should exist"); - let contours = store - .list_contours_for_layer(&layer.id) - .expect("contour query should succeed"); - let points = store - .list_points_for_contour(&contours[0].id) - .expect("point query should succeed"); - - assert_eq!(layer.width, 720.0); + let contours = layer.contours(); + let (_, contour) = contours.iter().next().expect("contour should exist"); + let points = contour.points(); + + assert_eq!(layer.width(), 720.0); assert_eq!(contours.len(), 1); - assert!(!contours[0].closed); + assert!(!contour.is_closed()); assert_eq!(points.len(), 1); - assert_eq!(points[0].id, point_id.to_string()); - assert_eq!((points[0].x, points[0].y), (40.0, 50.0)); - assert_eq!(points[0].point_type, "onCurve"); + assert_eq!(points[0].id(), point_id); + assert_eq!((points[0].x(), points[0].y()), (40.0, 50.0)); + assert_eq!(points[0].point_type(), shift_font::PointType::OnCurve); } #[test] @@ -530,16 +549,17 @@ fn applies_layer_geometry_replacement() { ])) .expect("change set should apply"); - let contours = store - .list_contours_for_layer(&LayerId::new(layer.id().to_string())) - .expect("contour query should succeed"); - let points = store - .list_points_for_contour(&contours[0].id) - .expect("point query should succeed"); + let layer = store + .load_glyph_layer(&layer.id()) + .expect("layer query should succeed") + .expect("layer should exist"); + let contours = layer.contours(); + let (_, contour) = contours.iter().next().expect("contour should exist"); + let points = contour.points(); assert_eq!(contours.len(), 1); assert_eq!(points.len(), 1); - assert_eq!((points[0].x, points[0].y), (10.0, 20.0)); + assert_eq!((points[0].x(), points[0].y()), (10.0, 20.0)); } #[test] @@ -552,15 +572,16 @@ fn layer_geometry_replacement_round_trips_anchors() { .apply_change_set(&anchored_layer_change_set(&glyph, &layer)) .expect("change set should apply"); - let anchors = store - .list_anchors_for_layer(&LayerId::new(layer.id().to_string())) - .expect("anchor query should succeed"); + let layer = store + .load_glyph_layer(&layer.id()) + .expect("anchor query should succeed") + .expect("layer should exist"); + let anchors = layer.anchors(); assert_eq!(anchors.len(), 1); - assert_eq!(anchors[0].id, anchor_id.to_string()); - assert_eq!(anchors[0].name.as_deref(), Some("top")); - assert_eq!((anchors[0].x, anchors[0].y), (250.0, 700.0)); - assert_eq!(anchors[0].order_index, 0); + assert_eq!(anchors[0].id(), anchor_id); + assert_eq!(anchors[0].name(), Some("top")); + assert_eq!((anchors[0].x(), anchors[0].y()), (250.0, 700.0)); } #[test] @@ -585,10 +606,12 @@ fn applies_anchor_position_changes() { ])) .expect("anchor positions should apply"); - let anchors = store - .list_anchors_for_layer(&LayerId::new(layer.id().to_string())) - .expect("anchor query should succeed"); - assert_eq!((anchors[0].x, anchors[0].y), (300.0, 650.0)); + let layer = store + .load_glyph_layer(&layer.id()) + .expect("anchor query should succeed") + .expect("layer should exist"); + let anchors = layer.anchors(); + assert_eq!((anchors[0].x(), anchors[0].y()), (300.0, 650.0)); } #[test] @@ -634,14 +657,16 @@ fn reopen_preserves_layer_anchors() { } let store = ShiftStore::open(&path).expect("reopen"); - let anchors = store - .list_anchors_for_layer(&LayerId::new(layer.id().to_string())) - .expect("anchor query should succeed"); + let layer = store + .load_glyph_layer(&layer.id()) + .expect("anchor query should succeed") + .expect("layer should exist"); + let anchors = layer.anchors(); assert_eq!(anchors.len(), 1); - assert_eq!(anchors[0].id, anchor_id.to_string()); - assert_eq!(anchors[0].name.as_deref(), Some("top")); - assert_eq!((anchors[0].x, anchors[0].y), (250.0, 700.0)); + assert_eq!(anchors[0].id(), anchor_id); + assert_eq!(anchors[0].name(), Some("top")); + assert_eq!((anchors[0].x(), anchors[0].y()), (250.0, 700.0)); std::fs::remove_dir_all(path.parent().unwrap()).ok(); } @@ -785,37 +810,45 @@ fn create_default_glyph_layer( store: &mut ShiftStore, glyph_id: &GlyphId, source_id: &SourceId, -) -> LayerId { - let layer_id = LayerId::new(shift_font::LayerId::from_raw("A-regular").to_string()); +) -> shift_font::LayerId { + let layer = shift_font::GlyphLayer::with_width( + shift_font::LayerId::from_raw("A-regular"), + shift_font::SourceId::from_raw(source_id.as_str()), + 0.0, + ); store - .create_glyph_layer(NewGlyphLayer { - id: layer_id.clone(), - glyph_id: glyph_id.clone(), - source_id: source_id.clone(), - name: Some("Regular".to_string()), - }) + .apply_change_set(&shift_font::FontChangeSet::new(vec![ + shift_font::FontChange::glyph_layer_created( + shift_font::GlyphId::from_raw(glyph_id.as_str()), + Some(shift_font::GlyphName::from("Regular")), + &layer, + ), + ])) .expect("glyph layer should be created"); - layer_id + layer.id() } fn create_default_component( store: &mut ShiftStore, - layer_id: &LayerId, + layer_id: &shift_font::LayerId, base_glyph_id: &GlyphId, -) -> ComponentId { - let component_id = ComponentId::new(shift_font::ComponentId::from_raw("A-B").to_string()); +) -> shift_font::ComponentId { + let component_id = shift_font::ComponentId::from_raw("A-B"); + let mut layer = store + .load_glyph_layer(layer_id) + .expect("glyph layer query should succeed") + .expect("glyph layer should exist"); + layer.add_component(shift_font::Component::with_id( + component_id.clone(), + shift_font::GlyphId::from_raw(base_glyph_id.as_str()), + "B", + shift_font::DecomposedTransform::identity(), + )); store - .create_glyph_component(NewGlyphComponent { - id: component_id.clone(), - layer_id: layer_id.clone(), - base_glyph_id: base_glyph_id.clone(), - base_glyph_name: "B".to_string(), - transform: shift_font::DecomposedTransform::identity(), - order_index: 0, - }) + .replace_glyph_layer(&layer) .expect("glyph component should be created"); component_id From 1437c035ae6102cfcbbd2a1d85e287212c3301fa Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 08:22:10 +0000 Subject: [PATCH 09/29] Modularize packed glyph codecs --- Cargo.lock | 76 ++ crates/shift-glyph-codec/Cargo.toml | 5 + crates/shift-glyph-codec/SPECIFICATION.md | 16 +- crates/shift-glyph-codec/src/frame.rs | 47 ++ crates/shift-glyph-codec/src/layer.rs | 44 +- crates/shift-glyph-codec/src/lib.rs | 678 +---------------- crates/shift-glyph-codec/src/outline.rs | 664 +++++++++++++++++ crates/shift-glyph-codec/tests/fixtures.rs | 138 ++++ fixtures/glyph-codec/outline-v1/vectors.json | 15 +- packages/glyph-codec/README.md | 6 + packages/glyph-codec/src/fixtures.test.ts | 100 +++ packages/glyph-codec/src/frame.ts | 50 ++ packages/glyph-codec/src/layer-binary.ts | 143 ++++ packages/glyph-codec/src/layer-lib.ts | 255 +++++++ packages/glyph-codec/src/layer-limits.ts | 31 + packages/glyph-codec/src/layer-strings.ts | 148 ++++ packages/glyph-codec/src/layer.ts | 731 ++++--------------- packages/glyph-codec/src/outline.ts | 35 +- 18 files changed, 1857 insertions(+), 1325 deletions(-) create mode 100644 crates/shift-glyph-codec/src/frame.rs create mode 100644 crates/shift-glyph-codec/src/outline.rs create mode 100644 crates/shift-glyph-codec/tests/fixtures.rs create mode 100644 packages/glyph-codec/src/fixtures.test.ts create mode 100644 packages/glyph-codec/src/frame.ts create mode 100644 packages/glyph-codec/src/layer-binary.ts create mode 100644 packages/glyph-codec/src/layer-lib.ts create mode 100644 packages/glyph-codec/src/layer-limits.ts create mode 100644 packages/glyph-codec/src/layer-strings.ts diff --git a/Cargo.lock b/Cargo.lock index fe681faf..f93c0e7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,15 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -438,6 +447,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -510,6 +528,16 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "0.11.1" @@ -602,6 +630,16 @@ dependencies = [ "syn", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dispatch2" version = "0.3.1" @@ -974,6 +1012,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -2173,6 +2221,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shift-backends" version = "0.1.0" @@ -2251,6 +2310,11 @@ dependencies = [ [[package]] name = "shift-glyph-codec" version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2", +] [[package]] name = "shift-slug" @@ -2590,6 +2654,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "ufo2fontir" version = "0.4.0" @@ -2712,6 +2782,12 @@ dependencies = [ "rustversion", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" diff --git a/crates/shift-glyph-codec/Cargo.toml b/crates/shift-glyph-codec/Cargo.toml index 8b76d1ce..07ad34ad 100644 --- a/crates/shift-glyph-codec/Cargo.toml +++ b/crates/shift-glyph-codec/Cargo.toml @@ -7,3 +7,8 @@ license = "MIT OR Apache-2.0" [lib] crate-type = ["rlib"] + +[dev-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" diff --git a/crates/shift-glyph-codec/SPECIFICATION.md b/crates/shift-glyph-codec/SPECIFICATION.md index 13a160a7..1c2405af 100644 --- a/crates/shift-glyph-codec/SPECIFICATION.md +++ b/crates/shift-glyph-codec/SPECIFICATION.md @@ -313,6 +313,14 @@ The Rust borrowed view and TypeScript iterable view expose one layer's structure incrementally. Consumers can build derived projections one contour/entity at a time without constructing a complete font or retaining every editable layer. +## Implementation structure + +Both implementations share one frame parser/writer across payload kinds. Rust +keeps outline and layer codecs in separate modules. TypeScript separates frame, +binary cursor/writer, string-table, lib-value, and limit handling from the +layer views. Successful TypeScript encoding constructs its validated offsets as +it writes; only externally supplied bytes run the complete decoder. + ## Golden vectors Canonical binary vectors are checked in at: @@ -320,6 +328,8 @@ Canonical binary vectors are checked in at: - `fixtures/glyph-codec/outline-v1/` - `fixtures/glyph-codec/layer-v1/` -Rust and TypeScript tests consume the same files, compare semantic decode, and -require re-encoding to reproduce every byte. Any accepted canonical payload must -round-trip byte-for-byte. +Each fixture directory includes a `vectors.json` manifest. Dedicated Rust and +TypeScript fixture tests parse those manifests, verify every declared byte +length and SHA-256 digest, decode the bytes, and require canonical re-encoding +to reproduce every byte. Format behavior tests remain separate. Any accepted +canonical payload must round-trip byte-for-byte. diff --git a/crates/shift-glyph-codec/src/frame.rs b/crates/shift-glyph-codec/src/frame.rs new file mode 100644 index 00000000..d78c94c0 --- /dev/null +++ b/crates/shift-glyph-codec/src/frame.rs @@ -0,0 +1,47 @@ +pub(crate) const FRAME_PREFIX_LEN: usize = 8; +const MAGIC: &[u8; 4] = b"SHFT"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FrameError { + HeaderTruncated { actual: usize }, + WrongMagic, + UnsupportedPayloadKind(u8), + UnsupportedVersion(u8), + UnknownFlags(u16), +} + +pub(crate) fn validate_frame( + bytes: &[u8], + header_len: usize, + expected_kind: u8, + expected_version: u8, +) -> Result<(), FrameError> { + if bytes.len() < header_len { + return Err(FrameError::HeaderTruncated { + actual: bytes.len(), + }); + } + if &bytes[..4] != MAGIC { + return Err(FrameError::WrongMagic); + } + if bytes[4] != expected_kind { + return Err(FrameError::UnsupportedPayloadKind(bytes[4])); + } + if bytes[5] != expected_version { + return Err(FrameError::UnsupportedVersion(bytes[5])); + } + + let flags = u16::from_le_bytes([bytes[6], bytes[7]]); + if flags != 0 { + return Err(FrameError::UnknownFlags(flags)); + } + Ok(()) +} + +pub(crate) fn write_frame(bytes: &mut [u8], kind: u8, version: u8) { + assert!(bytes.len() >= FRAME_PREFIX_LEN); + bytes[..4].copy_from_slice(MAGIC); + bytes[4] = kind; + bytes[5] = version; + bytes[6..8].copy_from_slice(&0_u16.to_le_bytes()); +} diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs index eb66e708..4b24f104 100644 --- a/crates/shift-glyph-codec/src/layer.rs +++ b/crates/shift-glyph-codec/src/layer.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::{error::Error, fmt}; -const MAGIC: &[u8; 4] = b"SHFT"; +use crate::frame::{validate_frame, write_frame, FrameError}; const LAYER_KIND: u8 = 0x02; const LAYER_VERSION: u8 = 0x01; const HEADER_LEN: usize = 72; @@ -333,6 +333,18 @@ impl fmt::Display for LayerCodecError { impl Error for LayerCodecError {} +impl From for LayerCodecError { + fn from(error: FrameError) -> Self { + match error { + FrameError::HeaderTruncated { actual } => Self::HeaderTruncated { actual }, + FrameError::WrongMagic => Self::WrongMagic, + FrameError::UnsupportedPayloadKind(kind) => Self::UnsupportedPayloadKind(kind), + FrameError::UnsupportedVersion(version) => Self::UnsupportedVersion(version), + FrameError::UnknownFlags(flags) => Self::UnknownFlags(flags), + } + } +} + /// Owned canonical `shift.glyph-layer.v1` bytes. #[derive(Clone, Debug, Eq, PartialEq)] pub struct PackedGlyphLayer(Vec); @@ -971,10 +983,7 @@ pub fn pack_layer(layer: &GlyphLayer) -> Result Result Result, LayerCodecError> { - if bytes.len() < HEADER_LEN { - return Err(LayerCodecError::HeaderTruncated { - actual: bytes.len(), - }); - } + validate_frame(bytes, HEADER_LEN, LAYER_KIND, LAYER_VERSION).map_err(LayerCodecError::from)?; check_limit("payload byte length", bytes.len(), MAX_LAYER_PAYLOAD_BYTES)?; - if &bytes[..4] != MAGIC { - return Err(LayerCodecError::WrongMagic); - } - if bytes[4] != LAYER_KIND { - return Err(LayerCodecError::UnsupportedPayloadKind(bytes[4])); - } - if bytes[5] != LAYER_VERSION { - return Err(LayerCodecError::UnsupportedVersion(bytes[5])); - } - let frame_flags = read_u16(bytes, 6); - if frame_flags != 0 { - return Err(LayerCodecError::UnknownFlags(frame_flags)); - } let declared_len = read_u32(bytes, 8) as usize; if declared_len != bytes.len() { return Err(LayerCodecError::LengthMismatch { @@ -1697,9 +1689,6 @@ where bytes.extend_from_slice(&value.to_le_bytes()); Ok(()) } -fn write_u16(bytes: &mut [u8], offset: usize, value: u16) { - bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); -} fn write_u32(bytes: &mut [u8], offset: usize, value: T) -> Result<(), LayerCodecError> where T: TryInto, @@ -1711,9 +1700,6 @@ where bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); Ok(()) } -fn read_u16(bytes: &[u8], offset: usize) -> u16 { - u16::from_le_bytes(bytes[offset..offset + 2].try_into().expect("fixed width")) -} fn read_u32(bytes: &[u8], offset: usize) -> u32 { u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) } diff --git a/crates/shift-glyph-codec/src/lib.rs b/crates/shift-glyph-codec/src/lib.rs index ae23e416..e7792aab 100644 --- a/crates/shift-glyph-codec/src/lib.rs +++ b/crates/shift-glyph-codec/src/lib.rs @@ -4,681 +4,9 @@ //! dependency on `shift-font`: authored geometry adaptation belongs to the //! domain/transport boundary, not to the codec. -use std::{error::Error, fmt}; - +mod frame; mod layer; +mod outline; pub use layer::*; - -const MAGIC: &[u8; 4] = b"SHFT"; -const OUTLINE_KIND: u8 = 0x01; -const OUTLINE_VERSION: u8 = 0x01; -const HEADER_LEN: usize = 16; - -/// Maximum command count accepted by the v1 implementation. -pub const MAX_COMMAND_COUNT: usize = 1_000_000; -/// Maximum coordinate count accepted by the v1 implementation. -pub const MAX_COORD_COUNT: usize = 6_000_000; -/// Maximum total payload size accepted by the v1 implementation. -pub const MAX_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; - -/// One codec-owned drawing command. -/// -/// Encoders accept `OutlineCommand` and perform the specified finite f64 -/// to f32 conversion. Decoded iterators yield `OutlineCommand` directly -/// from the validated payload. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum OutlineCommand { - Move { - x: T, - y: T, - }, - Line { - x: T, - y: T, - }, - Quad { - cx: T, - cy: T, - x: T, - y: T, - }, - Cubic { - c1x: T, - c1y: T, - c2x: T, - c2y: T, - x: T, - y: T, - }, - Close, -} - -impl OutlineCommand { - fn byte(&self) -> u8 { - match self { - Self::Move { .. } => 0, - Self::Line { .. } => 1, - Self::Quad { .. } => 2, - Self::Cubic { .. } => 3, - Self::Close => 4, - } - } - - fn arity(&self) -> usize { - match self { - Self::Move { .. } | Self::Line { .. } => 2, - Self::Quad { .. } => 4, - Self::Cubic { .. } => 6, - Self::Close => 0, - } - } -} - -/// The command-stream rule violated by an encoder or decoder. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CommandOrderError { - DrawingCommandWithoutContour, - CloseWithoutContour, - CloseWithoutDrawingSegment, -} - -impl fmt::Display for CommandOrderError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::DrawingCommandWithoutContour => { - f.write_str("drawing command requires an active contour") - } - Self::CloseWithoutContour => f.write_str("close requires an active contour"), - Self::CloseWithoutDrawingSegment => { - f.write_str("close requires at least one drawing segment") - } - } - } -} - -/// Strict outline codec failure. -#[derive(Debug, Eq, PartialEq)] -pub enum CodecError { - HeaderTruncated { - actual: usize, - }, - WrongMagic, - UnsupportedPayloadKind(u8), - UnsupportedVersion(u8), - UnknownFlags(u16), - UnknownCommand { - index: usize, - command: u8, - }, - InvalidCommandOrder { - index: usize, - reason: CommandOrderError, - }, - CoordinateCountMismatch { - expected: usize, - actual: usize, - }, - NonZeroPadding { - offset: usize, - }, - NonFiniteCoordinate { - index: usize, - }, - NonFiniteInputCoordinate { - index: usize, - }, - CoordinateOutOfF32Range { - index: usize, - }, - LengthOverflow, - LengthMismatch { - expected: usize, - actual: usize, - }, - LimitExceeded { - field: &'static str, - limit: usize, - actual: usize, - }, -} - -impl fmt::Display for CodecError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::HeaderTruncated { actual } => { - write!( - f, - "outline header is truncated: {actual} of {HEADER_LEN} bytes" - ) - } - Self::WrongMagic => f.write_str("wrong glyph-codec magic"), - Self::UnsupportedPayloadKind(kind) => { - write!(f, "unsupported glyph-codec payload kind {kind:#04x}") - } - Self::UnsupportedVersion(version) => { - write!(f, "unsupported glyph-outline version {version}") - } - Self::UnknownFlags(flags) => write!(f, "unknown glyph-outline flags {flags:#06x}"), - Self::UnknownCommand { index, command } => { - write!(f, "unknown outline command {command:#04x} at index {index}") - } - Self::InvalidCommandOrder { index, reason } => { - write!( - f, - "invalid outline command ordering at index {index}: {reason}" - ) - } - Self::CoordinateCountMismatch { expected, actual } => write!( - f, - "outline command arity requires {expected} coordinates, header declares {actual}" - ), - Self::NonZeroPadding { offset } => { - write!(f, "non-zero outline alignment padding at byte {offset}") - } - Self::NonFiniteCoordinate { index } => { - write!(f, "non-finite outline coordinate at index {index}") - } - Self::NonFiniteInputCoordinate { index } => { - write!(f, "non-finite input coordinate at index {index}") - } - Self::CoordinateOutOfF32Range { index } => { - write!( - f, - "input coordinate at index {index} is outside finite f32 range" - ) - } - Self::LengthOverflow => f.write_str("outline payload length arithmetic overflowed"), - Self::LengthMismatch { expected, actual } => write!( - f, - "outline payload length mismatch: expected {expected} bytes, got {actual}" - ), - Self::LimitExceeded { - field, - limit, - actual, - } => write!( - f, - "outline {field} exceeds implementation limit {limit}: got {actual}" - ), - } - } -} - -impl Error for CodecError {} - -/// An owned, canonical `shift.glyph-outline.v1` payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PackedGlyphOutline(Vec); - -impl PackedGlyphOutline { - /// Validates and takes ownership of an existing payload. - pub fn from_bytes(bytes: Vec) -> Result { - decode_outline(&bytes)?; - Ok(Self(bytes)) - } - - /// Returns the canonical encoded bytes. - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - /// Consumes the opaque value and returns its canonical bytes. - pub fn into_bytes(self) -> Vec { - self.0 - } - - /// Returns a validated, allocation-free view over this payload. - pub fn view(&self) -> OutlineView<'_> { - // Construction and decoding are the only ways to create this type. - decode_outline(&self.0).expect("PackedGlyphOutline invariant violated") - } -} - -/// Incremental canonical encoder used by domain adapters to avoid allocating an -/// intermediate command-object graph. -#[derive(Debug, Default)] -pub struct OutlineEncoder { - commands: Vec, - coordinates: Vec, - state: CommandState, -} - -impl OutlineEncoder { - pub fn new() -> Self { - Self::default() - } - - /// Creates an encoder with checked capacities. - pub fn with_capacity( - command_count: usize, - coordinate_count: usize, - ) -> Result { - check_limit("command count", command_count, MAX_COMMAND_COUNT)?; - check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; - - Ok(Self { - commands: Vec::with_capacity(command_count), - coordinates: Vec::with_capacity(coordinate_count), - state: CommandState::default(), - }) - } - - /// Adds one command after validating ordering and every converted coordinate. - pub fn push(&mut self, command: OutlineCommand) -> Result<(), CodecError> { - let command_index = self.commands.len(); - check_limit( - "command count", - command_index - .checked_add(1) - .ok_or(CodecError::LengthOverflow)?, - MAX_COMMAND_COUNT, - )?; - let mut next_state = self.state; - validate_order(&mut next_state, command.byte(), command_index)?; - - let coordinate_index = self.coordinates.len(); - let arity = command.arity(); - check_limit( - "coordinate count", - coordinate_index - .checked_add(arity) - .ok_or(CodecError::LengthOverflow)?, - MAX_COORD_COUNT, - )?; - - let mut converted = [0.0_f32; 6]; - let count = match command { - OutlineCommand::Move { x, y } | OutlineCommand::Line { x, y } => { - converted[0] = convert_coordinate(x, coordinate_index)?; - converted[1] = convert_coordinate(y, coordinate_index + 1)?; - 2 - } - OutlineCommand::Quad { cx, cy, x, y } => { - for (slot, (value, index)) in converted.iter_mut().zip( - [cx, cy, x, y] - .into_iter() - .zip(coordinate_index..coordinate_index + 4), - ) { - *slot = convert_coordinate(value, index)?; - } - 4 - } - OutlineCommand::Cubic { - c1x, - c1y, - c2x, - c2y, - x, - y, - } => { - for (slot, (value, index)) in converted.iter_mut().zip( - [c1x, c1y, c2x, c2y, x, y] - .into_iter() - .zip(coordinate_index..coordinate_index + 6), - ) { - *slot = convert_coordinate(value, index)?; - } - 6 - } - OutlineCommand::Close => 0, - }; - - self.state = next_state; - self.commands.push(command.byte()); - self.coordinates.extend_from_slice(&converted[..count]); - Ok(()) - } - - /// Finishes the payload with canonical zero padding and little-endian f32s. - pub fn finish(self) -> Result { - let command_count = self.commands.len(); - let coordinate_count = self.coordinates.len(); - let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; - let coordinate_bytes = coordinate_count - .checked_mul(4) - .ok_or(CodecError::LengthOverflow)?; - let total_len = HEADER_LEN - .checked_add(aligned_commands) - .and_then(|value| value.checked_add(coordinate_bytes)) - .ok_or(CodecError::LengthOverflow)?; - check_limit("payload byte length", total_len, MAX_PAYLOAD_BYTES)?; - - let command_count = u32::try_from(command_count).map_err(|_| CodecError::LengthOverflow)?; - let coordinate_count = - u32::try_from(coordinate_count).map_err(|_| CodecError::LengthOverflow)?; - let mut bytes = Vec::with_capacity(total_len); - bytes.extend_from_slice(MAGIC); - bytes.push(OUTLINE_KIND); - bytes.push(OUTLINE_VERSION); - bytes.extend_from_slice(&0_u16.to_le_bytes()); - bytes.extend_from_slice(&command_count.to_le_bytes()); - bytes.extend_from_slice(&coordinate_count.to_le_bytes()); - bytes.extend_from_slice(&self.commands); - bytes.resize(HEADER_LEN + aligned_commands, 0); - for coordinate in self.coordinates { - bytes.extend_from_slice(&coordinate.to_le_bytes()); - } - - debug_assert_eq!(bytes.len(), total_len); - Ok(PackedGlyphOutline(bytes)) - } -} - -/// Canonically encodes a codec-owned command slice. -pub fn pack_outline(commands: &[OutlineCommand]) -> Result { - let coordinate_count = commands.iter().try_fold(0_usize, |count, command| { - count - .checked_add(command.arity()) - .ok_or(CodecError::LengthOverflow) - })?; - let mut encoder = OutlineEncoder::with_capacity(commands.len(), coordinate_count)?; - for command in commands { - encoder.push(*command)?; - } - encoder.finish() -} - -/// A validated, borrowed outline payload view. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OutlineView<'a> { - bytes: &'a [u8], - command_count: usize, - coordinate_count: usize, - coordinate_offset: usize, -} - -impl<'a> OutlineView<'a> { - pub fn command_count(&self) -> usize { - self.command_count - } - - pub fn coordinate_count(&self) -> usize { - self.coordinate_count - } - - /// Iterates decoded commands without allocating points or offset tables. - pub fn commands(&self) -> OutlineCommandIter<'a> { - OutlineCommandIter { - bytes: self.bytes, - command_count: self.command_count, - command_index: 0, - coordinate_offset: self.coordinate_offset, - } - } - - /// Iterates all coordinates in command-stream order. - pub fn coordinates(&self) -> CoordinateIter<'a> { - CoordinateIter { - bytes: self.bytes, - offset: self.coordinate_offset, - remaining: self.coordinate_count, - } - } -} - -/// Strictly validates one outline buffer and returns an allocation-free view. -pub fn decode_outline(bytes: &[u8]) -> Result, CodecError> { - if bytes.len() < HEADER_LEN { - return Err(CodecError::HeaderTruncated { - actual: bytes.len(), - }); - } - check_limit("payload byte length", bytes.len(), MAX_PAYLOAD_BYTES)?; - - if &bytes[..4] != MAGIC { - return Err(CodecError::WrongMagic); - } - if bytes[4] != OUTLINE_KIND { - return Err(CodecError::UnsupportedPayloadKind(bytes[4])); - } - if bytes[5] != OUTLINE_VERSION { - return Err(CodecError::UnsupportedVersion(bytes[5])); - } - - let flags = u16::from_le_bytes([bytes[6], bytes[7]]); - if flags != 0 { - return Err(CodecError::UnknownFlags(flags)); - } - - let command_count = read_u32(bytes, 8) as usize; - let coordinate_count = read_u32(bytes, 12) as usize; - check_limit("command count", command_count, MAX_COMMAND_COUNT)?; - check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; - - let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; - let coordinate_bytes = coordinate_count - .checked_mul(4) - .ok_or(CodecError::LengthOverflow)?; - let expected_len = HEADER_LEN - .checked_add(aligned_commands) - .and_then(|value| value.checked_add(coordinate_bytes)) - .ok_or(CodecError::LengthOverflow)?; - check_limit("payload byte length", expected_len, MAX_PAYLOAD_BYTES)?; - if bytes.len() != expected_len { - return Err(CodecError::LengthMismatch { - expected: expected_len, - actual: bytes.len(), - }); - } - - let commands_end = HEADER_LEN + command_count; - let mut state = CommandState::default(); - let mut expected_coordinate_count = 0_usize; - for (index, command) in bytes[HEADER_LEN..commands_end].iter().copied().enumerate() { - let arity = command_arity(command).ok_or(CodecError::UnknownCommand { index, command })?; - validate_order(&mut state, command, index)?; - expected_coordinate_count = expected_coordinate_count - .checked_add(arity) - .ok_or(CodecError::LengthOverflow)?; - } - if expected_coordinate_count != coordinate_count { - return Err(CodecError::CoordinateCountMismatch { - expected: expected_coordinate_count, - actual: coordinate_count, - }); - } - - let coordinate_offset = HEADER_LEN + aligned_commands; - for (relative_offset, byte) in bytes[commands_end..coordinate_offset] - .iter() - .copied() - .enumerate() - { - if byte != 0 { - return Err(CodecError::NonZeroPadding { - offset: commands_end + relative_offset, - }); - } - } - - for index in 0..coordinate_count { - let coordinate = read_f32(bytes, coordinate_offset + index * 4); - if !coordinate.is_finite() { - return Err(CodecError::NonFiniteCoordinate { index }); - } - } - - Ok(OutlineView { - bytes, - command_count, - coordinate_count, - coordinate_offset, - }) -} - -/// Iterator over commands in a validated outline view. -pub struct OutlineCommandIter<'a> { - bytes: &'a [u8], - command_count: usize, - command_index: usize, - coordinate_offset: usize, -} - -impl Iterator for OutlineCommandIter<'_> { - type Item = OutlineCommand; - - fn next(&mut self) -> Option { - if self.command_index >= self.command_count { - return None; - } - - let command = self.bytes[HEADER_LEN + self.command_index]; - self.command_index += 1; - let mut next = || { - let value = read_f32(self.bytes, self.coordinate_offset); - self.coordinate_offset += 4; - value - }; - Some(match command { - 0 => OutlineCommand::Move { - x: next(), - y: next(), - }, - 1 => OutlineCommand::Line { - x: next(), - y: next(), - }, - 2 => OutlineCommand::Quad { - cx: next(), - cy: next(), - x: next(), - y: next(), - }, - 3 => OutlineCommand::Cubic { - c1x: next(), - c1y: next(), - c2x: next(), - c2y: next(), - x: next(), - y: next(), - }, - 4 => OutlineCommand::Close, - _ => unreachable!("validated command byte"), - }) - } - - fn size_hint(&self) -> (usize, Option) { - let remaining = self.command_count - self.command_index; - (remaining, Some(remaining)) - } -} - -impl ExactSizeIterator for OutlineCommandIter<'_> {} - -/// Iterator over f32 coordinates in a validated outline view. -pub struct CoordinateIter<'a> { - bytes: &'a [u8], - offset: usize, - remaining: usize, -} - -impl Iterator for CoordinateIter<'_> { - type Item = f32; - - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - - let value = read_f32(self.bytes, self.offset); - self.offset += 4; - self.remaining -= 1; - Some(value) - } - - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} - -impl ExactSizeIterator for CoordinateIter<'_> {} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum CommandState { - #[default] - BetweenContours, - ActiveWithoutSegment, - ActiveWithSegment, -} - -fn validate_order(state: &mut CommandState, command: u8, index: usize) -> Result<(), CodecError> { - match command { - 0 => *state = CommandState::ActiveWithoutSegment, - 1..=3 => match state { - CommandState::BetweenContours => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::DrawingCommandWithoutContour, - }); - } - CommandState::ActiveWithoutSegment | CommandState::ActiveWithSegment => { - *state = CommandState::ActiveWithSegment; - } - }, - 4 => match state { - CommandState::BetweenContours => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::CloseWithoutContour, - }); - } - CommandState::ActiveWithoutSegment => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::CloseWithoutDrawingSegment, - }); - } - CommandState::ActiveWithSegment => *state = CommandState::BetweenContours, - }, - _ => unreachable!("command byte validated before ordering"), - } - - Ok(()) -} - -fn command_arity(command: u8) -> Option { - match command { - 0 | 1 => Some(2), - 2 => Some(4), - 3 => Some(6), - 4 => Some(0), - _ => None, - } -} - -fn convert_coordinate(value: f64, index: usize) -> Result { - if !value.is_finite() { - return Err(CodecError::NonFiniteInputCoordinate { index }); - } - - let converted = value as f32; - if !converted.is_finite() { - return Err(CodecError::CoordinateOutOfF32Range { index }); - } - - Ok(converted) -} - -fn check_limit(field: &'static str, actual: usize, limit: usize) -> Result<(), CodecError> { - if actual > limit { - return Err(CodecError::LimitExceeded { - field, - limit, - actual, - }); - } - Ok(()) -} - -fn align4(value: usize) -> Option { - value.checked_add(3).map(|value| value & !3) -} - -fn read_u32(bytes: &[u8], offset: usize) -> u32 { - u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) -} - -fn read_f32(bytes: &[u8], offset: usize) -> f32 { - f32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) -} +pub use outline::*; diff --git a/crates/shift-glyph-codec/src/outline.rs b/crates/shift-glyph-codec/src/outline.rs new file mode 100644 index 00000000..521f99ac --- /dev/null +++ b/crates/shift-glyph-codec/src/outline.rs @@ -0,0 +1,664 @@ +use std::{error::Error, fmt}; + +use crate::frame::{validate_frame, write_frame, FrameError}; +const OUTLINE_KIND: u8 = 0x01; +const OUTLINE_VERSION: u8 = 0x01; +const HEADER_LEN: usize = 16; + +/// Maximum command count accepted by the v1 implementation. +pub const MAX_COMMAND_COUNT: usize = 1_000_000; +/// Maximum coordinate count accepted by the v1 implementation. +pub const MAX_COORD_COUNT: usize = 6_000_000; +/// Maximum total payload size accepted by the v1 implementation. +pub const MAX_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; + +/// One codec-owned drawing command. +/// +/// Encoders accept `OutlineCommand` and perform the specified finite f64 +/// to f32 conversion. Decoded iterators yield `OutlineCommand` directly +/// from the validated payload. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OutlineCommand { + Move { + x: T, + y: T, + }, + Line { + x: T, + y: T, + }, + Quad { + cx: T, + cy: T, + x: T, + y: T, + }, + Cubic { + c1x: T, + c1y: T, + c2x: T, + c2y: T, + x: T, + y: T, + }, + Close, +} + +impl OutlineCommand { + fn byte(&self) -> u8 { + match self { + Self::Move { .. } => 0, + Self::Line { .. } => 1, + Self::Quad { .. } => 2, + Self::Cubic { .. } => 3, + Self::Close => 4, + } + } + + fn arity(&self) -> usize { + match self { + Self::Move { .. } | Self::Line { .. } => 2, + Self::Quad { .. } => 4, + Self::Cubic { .. } => 6, + Self::Close => 0, + } + } +} + +/// The command-stream rule violated by an encoder or decoder. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandOrderError { + DrawingCommandWithoutContour, + CloseWithoutContour, + CloseWithoutDrawingSegment, +} + +impl fmt::Display for CommandOrderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DrawingCommandWithoutContour => { + f.write_str("drawing command requires an active contour") + } + Self::CloseWithoutContour => f.write_str("close requires an active contour"), + Self::CloseWithoutDrawingSegment => { + f.write_str("close requires at least one drawing segment") + } + } + } +} + +/// Strict outline codec failure. +#[derive(Debug, Eq, PartialEq)] +pub enum CodecError { + HeaderTruncated { + actual: usize, + }, + WrongMagic, + UnsupportedPayloadKind(u8), + UnsupportedVersion(u8), + UnknownFlags(u16), + UnknownCommand { + index: usize, + command: u8, + }, + InvalidCommandOrder { + index: usize, + reason: CommandOrderError, + }, + CoordinateCountMismatch { + expected: usize, + actual: usize, + }, + NonZeroPadding { + offset: usize, + }, + NonFiniteCoordinate { + index: usize, + }, + NonFiniteInputCoordinate { + index: usize, + }, + CoordinateOutOfF32Range { + index: usize, + }, + LengthOverflow, + LengthMismatch { + expected: usize, + actual: usize, + }, + LimitExceeded { + field: &'static str, + limit: usize, + actual: usize, + }, +} + +impl fmt::Display for CodecError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HeaderTruncated { actual } => { + write!( + f, + "outline header is truncated: {actual} of {HEADER_LEN} bytes" + ) + } + Self::WrongMagic => f.write_str("wrong glyph-codec magic"), + Self::UnsupportedPayloadKind(kind) => { + write!(f, "unsupported glyph-codec payload kind {kind:#04x}") + } + Self::UnsupportedVersion(version) => { + write!(f, "unsupported glyph-outline version {version}") + } + Self::UnknownFlags(flags) => write!(f, "unknown glyph-outline flags {flags:#06x}"), + Self::UnknownCommand { index, command } => { + write!(f, "unknown outline command {command:#04x} at index {index}") + } + Self::InvalidCommandOrder { index, reason } => { + write!( + f, + "invalid outline command ordering at index {index}: {reason}" + ) + } + Self::CoordinateCountMismatch { expected, actual } => write!( + f, + "outline command arity requires {expected} coordinates, header declares {actual}" + ), + Self::NonZeroPadding { offset } => { + write!(f, "non-zero outline alignment padding at byte {offset}") + } + Self::NonFiniteCoordinate { index } => { + write!(f, "non-finite outline coordinate at index {index}") + } + Self::NonFiniteInputCoordinate { index } => { + write!(f, "non-finite input coordinate at index {index}") + } + Self::CoordinateOutOfF32Range { index } => { + write!( + f, + "input coordinate at index {index} is outside finite f32 range" + ) + } + Self::LengthOverflow => f.write_str("outline payload length arithmetic overflowed"), + Self::LengthMismatch { expected, actual } => write!( + f, + "outline payload length mismatch: expected {expected} bytes, got {actual}" + ), + Self::LimitExceeded { + field, + limit, + actual, + } => write!( + f, + "outline {field} exceeds implementation limit {limit}: got {actual}" + ), + } + } +} + +impl Error for CodecError {} + +impl From for CodecError { + fn from(error: FrameError) -> Self { + match error { + FrameError::HeaderTruncated { actual } => Self::HeaderTruncated { actual }, + FrameError::WrongMagic => Self::WrongMagic, + FrameError::UnsupportedPayloadKind(kind) => Self::UnsupportedPayloadKind(kind), + FrameError::UnsupportedVersion(version) => Self::UnsupportedVersion(version), + FrameError::UnknownFlags(flags) => Self::UnknownFlags(flags), + } + } +} + +/// An owned, canonical `shift.glyph-outline.v1` payload. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PackedGlyphOutline(Vec); + +impl PackedGlyphOutline { + /// Validates and takes ownership of an existing payload. + pub fn from_bytes(bytes: Vec) -> Result { + decode_outline(&bytes)?; + Ok(Self(bytes)) + } + + /// Returns the canonical encoded bytes. + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + + /// Consumes the opaque value and returns its canonical bytes. + pub fn into_bytes(self) -> Vec { + self.0 + } + + /// Returns a validated, allocation-free view over this payload. + pub fn view(&self) -> OutlineView<'_> { + // Construction and decoding are the only ways to create this type. + decode_outline(&self.0).expect("PackedGlyphOutline invariant violated") + } +} + +/// Incremental canonical encoder used by domain adapters to avoid allocating an +/// intermediate command-object graph. +#[derive(Debug, Default)] +pub struct OutlineEncoder { + commands: Vec, + coordinates: Vec, + state: CommandState, +} + +impl OutlineEncoder { + pub fn new() -> Self { + Self::default() + } + + /// Creates an encoder with checked capacities. + pub fn with_capacity( + command_count: usize, + coordinate_count: usize, + ) -> Result { + check_limit("command count", command_count, MAX_COMMAND_COUNT)?; + check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; + + Ok(Self { + commands: Vec::with_capacity(command_count), + coordinates: Vec::with_capacity(coordinate_count), + state: CommandState::default(), + }) + } + + /// Adds one command after validating ordering and every converted coordinate. + pub fn push(&mut self, command: OutlineCommand) -> Result<(), CodecError> { + let command_index = self.commands.len(); + check_limit( + "command count", + command_index + .checked_add(1) + .ok_or(CodecError::LengthOverflow)?, + MAX_COMMAND_COUNT, + )?; + let mut next_state = self.state; + validate_order(&mut next_state, command.byte(), command_index)?; + + let coordinate_index = self.coordinates.len(); + let arity = command.arity(); + check_limit( + "coordinate count", + coordinate_index + .checked_add(arity) + .ok_or(CodecError::LengthOverflow)?, + MAX_COORD_COUNT, + )?; + + let mut converted = [0.0_f32; 6]; + let count = match command { + OutlineCommand::Move { x, y } | OutlineCommand::Line { x, y } => { + converted[0] = convert_coordinate(x, coordinate_index)?; + converted[1] = convert_coordinate(y, coordinate_index + 1)?; + 2 + } + OutlineCommand::Quad { cx, cy, x, y } => { + for (slot, (value, index)) in converted.iter_mut().zip( + [cx, cy, x, y] + .into_iter() + .zip(coordinate_index..coordinate_index + 4), + ) { + *slot = convert_coordinate(value, index)?; + } + 4 + } + OutlineCommand::Cubic { + c1x, + c1y, + c2x, + c2y, + x, + y, + } => { + for (slot, (value, index)) in converted.iter_mut().zip( + [c1x, c1y, c2x, c2y, x, y] + .into_iter() + .zip(coordinate_index..coordinate_index + 6), + ) { + *slot = convert_coordinate(value, index)?; + } + 6 + } + OutlineCommand::Close => 0, + }; + + self.state = next_state; + self.commands.push(command.byte()); + self.coordinates.extend_from_slice(&converted[..count]); + Ok(()) + } + + /// Finishes the payload with canonical zero padding and little-endian f32s. + pub fn finish(self) -> Result { + let command_count = self.commands.len(); + let coordinate_count = self.coordinates.len(); + let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; + let coordinate_bytes = coordinate_count + .checked_mul(4) + .ok_or(CodecError::LengthOverflow)?; + let total_len = HEADER_LEN + .checked_add(aligned_commands) + .and_then(|value| value.checked_add(coordinate_bytes)) + .ok_or(CodecError::LengthOverflow)?; + check_limit("payload byte length", total_len, MAX_PAYLOAD_BYTES)?; + + let command_count = u32::try_from(command_count).map_err(|_| CodecError::LengthOverflow)?; + let coordinate_count = + u32::try_from(coordinate_count).map_err(|_| CodecError::LengthOverflow)?; + let mut bytes = vec![0; HEADER_LEN]; + write_frame(&mut bytes, OUTLINE_KIND, OUTLINE_VERSION); + bytes[8..12].copy_from_slice(&command_count.to_le_bytes()); + bytes[12..16].copy_from_slice(&coordinate_count.to_le_bytes()); + bytes.extend_from_slice(&self.commands); + bytes.resize(HEADER_LEN + aligned_commands, 0); + for coordinate in self.coordinates { + bytes.extend_from_slice(&coordinate.to_le_bytes()); + } + + debug_assert_eq!(bytes.len(), total_len); + Ok(PackedGlyphOutline(bytes)) + } +} + +/// Canonically encodes a codec-owned command slice. +pub fn pack_outline(commands: &[OutlineCommand]) -> Result { + let coordinate_count = commands.iter().try_fold(0_usize, |count, command| { + count + .checked_add(command.arity()) + .ok_or(CodecError::LengthOverflow) + })?; + let mut encoder = OutlineEncoder::with_capacity(commands.len(), coordinate_count)?; + for command in commands { + encoder.push(*command)?; + } + encoder.finish() +} + +/// A validated, borrowed outline payload view. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutlineView<'a> { + bytes: &'a [u8], + command_count: usize, + coordinate_count: usize, + coordinate_offset: usize, +} + +impl<'a> OutlineView<'a> { + pub fn command_count(&self) -> usize { + self.command_count + } + + pub fn coordinate_count(&self) -> usize { + self.coordinate_count + } + + /// Iterates decoded commands without allocating points or offset tables. + pub fn commands(&self) -> OutlineCommandIter<'a> { + OutlineCommandIter { + bytes: self.bytes, + command_count: self.command_count, + command_index: 0, + coordinate_offset: self.coordinate_offset, + } + } + + /// Iterates all coordinates in command-stream order. + pub fn coordinates(&self) -> CoordinateIter<'a> { + CoordinateIter { + bytes: self.bytes, + offset: self.coordinate_offset, + remaining: self.coordinate_count, + } + } +} + +/// Strictly validates one outline buffer and returns an allocation-free view. +pub fn decode_outline(bytes: &[u8]) -> Result, CodecError> { + validate_frame(bytes, HEADER_LEN, OUTLINE_KIND, OUTLINE_VERSION).map_err(CodecError::from)?; + check_limit("payload byte length", bytes.len(), MAX_PAYLOAD_BYTES)?; + + let command_count = read_u32(bytes, 8) as usize; + let coordinate_count = read_u32(bytes, 12) as usize; + check_limit("command count", command_count, MAX_COMMAND_COUNT)?; + check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; + + let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; + let coordinate_bytes = coordinate_count + .checked_mul(4) + .ok_or(CodecError::LengthOverflow)?; + let expected_len = HEADER_LEN + .checked_add(aligned_commands) + .and_then(|value| value.checked_add(coordinate_bytes)) + .ok_or(CodecError::LengthOverflow)?; + check_limit("payload byte length", expected_len, MAX_PAYLOAD_BYTES)?; + if bytes.len() != expected_len { + return Err(CodecError::LengthMismatch { + expected: expected_len, + actual: bytes.len(), + }); + } + + let commands_end = HEADER_LEN + command_count; + let mut state = CommandState::default(); + let mut expected_coordinate_count = 0_usize; + for (index, command) in bytes[HEADER_LEN..commands_end].iter().copied().enumerate() { + let arity = command_arity(command).ok_or(CodecError::UnknownCommand { index, command })?; + validate_order(&mut state, command, index)?; + expected_coordinate_count = expected_coordinate_count + .checked_add(arity) + .ok_or(CodecError::LengthOverflow)?; + } + if expected_coordinate_count != coordinate_count { + return Err(CodecError::CoordinateCountMismatch { + expected: expected_coordinate_count, + actual: coordinate_count, + }); + } + + let coordinate_offset = HEADER_LEN + aligned_commands; + for (relative_offset, byte) in bytes[commands_end..coordinate_offset] + .iter() + .copied() + .enumerate() + { + if byte != 0 { + return Err(CodecError::NonZeroPadding { + offset: commands_end + relative_offset, + }); + } + } + + for index in 0..coordinate_count { + let coordinate = read_f32(bytes, coordinate_offset + index * 4); + if !coordinate.is_finite() { + return Err(CodecError::NonFiniteCoordinate { index }); + } + } + + Ok(OutlineView { + bytes, + command_count, + coordinate_count, + coordinate_offset, + }) +} + +/// Iterator over commands in a validated outline view. +pub struct OutlineCommandIter<'a> { + bytes: &'a [u8], + command_count: usize, + command_index: usize, + coordinate_offset: usize, +} + +impl Iterator for OutlineCommandIter<'_> { + type Item = OutlineCommand; + + fn next(&mut self) -> Option { + if self.command_index >= self.command_count { + return None; + } + + let command = self.bytes[HEADER_LEN + self.command_index]; + self.command_index += 1; + let mut next = || { + let value = read_f32(self.bytes, self.coordinate_offset); + self.coordinate_offset += 4; + value + }; + Some(match command { + 0 => OutlineCommand::Move { + x: next(), + y: next(), + }, + 1 => OutlineCommand::Line { + x: next(), + y: next(), + }, + 2 => OutlineCommand::Quad { + cx: next(), + cy: next(), + x: next(), + y: next(), + }, + 3 => OutlineCommand::Cubic { + c1x: next(), + c1y: next(), + c2x: next(), + c2y: next(), + x: next(), + y: next(), + }, + 4 => OutlineCommand::Close, + _ => unreachable!("validated command byte"), + }) + } + + fn size_hint(&self) -> (usize, Option) { + let remaining = self.command_count - self.command_index; + (remaining, Some(remaining)) + } +} + +impl ExactSizeIterator for OutlineCommandIter<'_> {} + +/// Iterator over f32 coordinates in a validated outline view. +pub struct CoordinateIter<'a> { + bytes: &'a [u8], + offset: usize, + remaining: usize, +} + +impl Iterator for CoordinateIter<'_> { + type Item = f32; + + fn next(&mut self) -> Option { + if self.remaining == 0 { + return None; + } + + let value = read_f32(self.bytes, self.offset); + self.offset += 4; + self.remaining -= 1; + Some(value) + } + + fn size_hint(&self) -> (usize, Option) { + (self.remaining, Some(self.remaining)) + } +} + +impl ExactSizeIterator for CoordinateIter<'_> {} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum CommandState { + #[default] + BetweenContours, + ActiveWithoutSegment, + ActiveWithSegment, +} + +fn validate_order(state: &mut CommandState, command: u8, index: usize) -> Result<(), CodecError> { + match command { + 0 => *state = CommandState::ActiveWithoutSegment, + 1..=3 => match state { + CommandState::BetweenContours => { + return Err(CodecError::InvalidCommandOrder { + index, + reason: CommandOrderError::DrawingCommandWithoutContour, + }); + } + CommandState::ActiveWithoutSegment | CommandState::ActiveWithSegment => { + *state = CommandState::ActiveWithSegment; + } + }, + 4 => match state { + CommandState::BetweenContours => { + return Err(CodecError::InvalidCommandOrder { + index, + reason: CommandOrderError::CloseWithoutContour, + }); + } + CommandState::ActiveWithoutSegment => { + return Err(CodecError::InvalidCommandOrder { + index, + reason: CommandOrderError::CloseWithoutDrawingSegment, + }); + } + CommandState::ActiveWithSegment => *state = CommandState::BetweenContours, + }, + _ => unreachable!("command byte validated before ordering"), + } + + Ok(()) +} + +fn command_arity(command: u8) -> Option { + match command { + 0 | 1 => Some(2), + 2 => Some(4), + 3 => Some(6), + 4 => Some(0), + _ => None, + } +} + +fn convert_coordinate(value: f64, index: usize) -> Result { + if !value.is_finite() { + return Err(CodecError::NonFiniteInputCoordinate { index }); + } + + let converted = value as f32; + if !converted.is_finite() { + return Err(CodecError::CoordinateOutOfF32Range { index }); + } + + Ok(converted) +} + +fn check_limit(field: &'static str, actual: usize, limit: usize) -> Result<(), CodecError> { + if actual > limit { + return Err(CodecError::LimitExceeded { + field, + limit, + actual, + }); + } + Ok(()) +} + +fn align4(value: usize) -> Option { + value.checked_add(3).map(|value| value & !3) +} + +fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) +} + +fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) +} diff --git a/crates/shift-glyph-codec/tests/fixtures.rs b/crates/shift-glyph-codec/tests/fixtures.rs new file mode 100644 index 00000000..ddff7712 --- /dev/null +++ b/crates/shift-glyph-codec/tests/fixtures.rs @@ -0,0 +1,138 @@ +use std::{collections::HashSet, fs, path::PathBuf}; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use shift_glyph_codec::{decode_layer, decode_outline, pack_layer, pack_outline, OutlineCommand}; + +#[derive(Deserialize)] +struct LayerManifest { + format: String, + vectors: Vec, +} + +#[derive(Deserialize)] +struct BinaryVector { + file: String, + bytes: usize, + sha256: String, +} + +#[derive(Deserialize)] +struct OutlineVector { + name: String, + file: String, + bytes: usize, + sha256: String, + commands: Vec, +} + +#[derive(Deserialize)] +struct ManifestCommand { + kind: String, + coordinates: Vec, +} + +#[test] +fn layer_manifest_declares_every_canonical_fixture() { + let directory = fixture_directory("layer-v1"); + let manifest: LayerManifest = + serde_json::from_slice(&fs::read(directory.join("vectors.json")).unwrap()).unwrap(); + assert_eq!(manifest.format, "shift.glyph-layer.v1"); + assert_unique_files(manifest.vectors.iter().map(|vector| vector.file.as_str())); + + for vector in manifest.vectors { + let bytes = fs::read(directory.join(&vector.file)).unwrap(); + assert_binary_facts(&vector, &bytes); + let layer = decode_layer(&bytes).unwrap().unpack(); + assert_eq!(pack_layer(&layer).unwrap().as_bytes(), bytes); + } +} + +#[test] +fn outline_manifest_declares_every_canonical_fixture() { + let directory = fixture_directory("outline-v1"); + let vectors: Vec = + serde_json::from_slice(&fs::read(directory.join("vectors.json")).unwrap()).unwrap(); + assert_unique_files(vectors.iter().map(|vector| vector.file.as_str())); + assert_eq!( + vectors + .iter() + .map(|vector| vector.name.as_str()) + .collect::>() + .len(), + vectors.len() + ); + + for vector in vectors { + let bytes = fs::read(directory.join(&vector.file)).unwrap(); + assert_binary_facts( + &BinaryVector { + file: vector.file, + bytes: vector.bytes, + sha256: vector.sha256, + }, + &bytes, + ); + let commands = vector + .commands + .iter() + .map(manifest_command) + .collect::>(); + assert_eq!(pack_outline(&commands).unwrap().as_bytes(), bytes); + assert_eq!( + decode_outline(&bytes).unwrap().command_count(), + commands.len() + ); + } +} + +fn fixture_directory(format: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/glyph-codec") + .join(format) +} + +fn assert_unique_files<'a>(files: impl Iterator) { + let files = files.collect::>(); + assert_eq!( + files.iter().copied().collect::>().len(), + files.len() + ); +} + +fn assert_binary_facts(vector: &BinaryVector, bytes: &[u8]) { + assert_eq!(bytes.len(), vector.bytes, "{} byte length", vector.file); + assert_eq!( + hex(&Sha256::digest(bytes)), + vector.sha256, + "{} SHA-256", + vector.file + ); +} + +fn manifest_command(command: &ManifestCommand) -> OutlineCommand { + match (command.kind.as_str(), command.coordinates.as_slice()) { + ("move", [x, y]) => OutlineCommand::Move { x: *x, y: *y }, + ("line", [x, y]) => OutlineCommand::Line { x: *x, y: *y }, + ("quad", [cx, cy, x, y]) => OutlineCommand::Quad { + cx: *cx, + cy: *cy, + x: *x, + y: *y, + }, + ("cubic", [c1x, c1y, c2x, c2y, x, y]) => OutlineCommand::Cubic { + c1x: *c1x, + c1y: *c1y, + c2x: *c2x, + c2y: *c2y, + x: *x, + y: *y, + }, + ("close", []) => OutlineCommand::Close, + _ => panic!("invalid manifest command {}", command.kind), + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} diff --git a/fixtures/glyph-codec/outline-v1/vectors.json b/fixtures/glyph-codec/outline-v1/vectors.json index f13a5fd0..daad6c63 100644 --- a/fixtures/glyph-codec/outline-v1/vectors.json +++ b/fixtures/glyph-codec/outline-v1/vectors.json @@ -2,11 +2,15 @@ { "name": "empty", "file": "empty.bin", + "bytes": 16, + "sha256": "ae4497afa69fec3f60441c2af7359117fb419fa9b473af219735b21359db3b1e", "commands": [] }, { "name": "closed-line", "file": "closed-line.bin", + "bytes": 36, + "sha256": "ff508aa8468d3ef1057cb47a8d9b507f0c81b0385821edde9a199c5cac3ab7b5", "commands": [ { "kind": "move", "coordinates": [0, 0] }, { "kind": "line", "coordinates": [100, 200] }, @@ -16,6 +20,8 @@ { "name": "open", "file": "open.bin", + "bytes": 36, + "sha256": "5115ef5e5b2249f877593011f9fa08359aef8cc7c90dee80a1568379091e059b", "commands": [ { "kind": "move", "coordinates": [-12.5, 0.25] }, { "kind": "line", "coordinates": [123.75, -456.5] } @@ -24,16 +30,23 @@ { "name": "quadratic-cubic", "file": "quadratic-cubic.bin", + "bytes": 68, + "sha256": "72fd95db7ed662d21cfa248d65ad8fdde1f3fd00efc524ee036fa449ac8797b6", "commands": [ { "kind": "move", "coordinates": [-1.5, 2.25] }, { "kind": "quad", "coordinates": [0.5, -0.75, 1000.125, -2000.5] }, - { "kind": "cubic", "coordinates": [-3, 4.5, 5.25, -6.75, 100000000000000000000, -100000000000000000000] }, + { + "kind": "cubic", + "coordinates": [-3, 4.5, 5.25, -6.75, 100000000000000000000, -100000000000000000000] + }, { "kind": "close", "coordinates": [] } ] }, { "name": "multiple-contours", "file": "multiple-contours.bin", + "bytes": 56, + "sha256": "f2e1e7b8666d5921f68f9d2d01caa3e7ec1b52712bf2b18bc7915e028788e748", "commands": [ { "kind": "move", "coordinates": [0, 0] }, { "kind": "line", "coordinates": [10, 0] }, diff --git a/packages/glyph-codec/README.md b/packages/glyph-codec/README.md index cc354945..b9636805 100644 --- a/packages/glyph-codec/README.md +++ b/packages/glyph-codec/README.md @@ -22,6 +22,12 @@ transformations belong to their respective consumers. Rust adaptation to the editable object model lives in `shift-font::packed_layer`, keeping the codec independent of font semantics. +Implementation modules keep responsibilities narrow: `frame.ts` owns the common +frame, `layer-binary.ts` owns bounded byte access, `layer-strings.ts` owns the +canonical first-use string table, `layer-lib.ts` owns nested lib values, and +`layer.ts` owns layer framing and iterable views. Packing retains writer-derived +offsets rather than decoding its own output a second time. + ```ts import { decodeLayer, packLayer } from "@shift/glyph-codec"; diff --git a/packages/glyph-codec/src/fixtures.test.ts b/packages/glyph-codec/src/fixtures.test.ts new file mode 100644 index 00000000..c99d32a4 --- /dev/null +++ b/packages/glyph-codec/src/fixtures.test.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { decodeLayer, packLayer } from "./layer"; +import { decodeOutline, packOutline } from "./outline"; +import type { OutlineCommand } from "./types"; + +type BinaryVector = { + readonly file: string; + readonly bytes: number; + readonly sha256: string; +}; + +type LayerManifest = { + readonly format: string; + readonly vectors: readonly BinaryVector[]; +}; + +type OutlineVector = BinaryVector & { + readonly name: string; + readonly commands: readonly ManifestCommand[]; +}; + +type ManifestCommand = { + readonly kind: string; + readonly coordinates: readonly number[]; +}; + +describe("canonical glyph-codec fixtures", () => { + it("declares every layer fixture's length, digest, and canonical bytes", () => { + const directory = fixtureUrl("layer-v1"); + const manifest = readJson(new URL("vectors.json", directory)); + expect(manifest.format).toBe("shift.glyph-layer.v1"); + expect(new Set(manifest.vectors.map((vector) => vector.file)).size).toBe( + manifest.vectors.length, + ); + + for (const vector of manifest.vectors) { + const bytes = readFileSync(new URL(vector.file, directory)); + expectBinaryFacts(bytes, vector); + expect([...packLayer(decodeLayer(bytes).unpack()).toUint8Array()]).toEqual([...bytes]); + } + }); + + it("declares every outline fixture's commands, length, digest, and canonical bytes", () => { + const directory = fixtureUrl("outline-v1"); + const vectors = readJson(new URL("vectors.json", directory)); + expect(new Set(vectors.map((vector) => vector.file)).size).toBe(vectors.length); + expect(new Set(vectors.map((vector) => vector.name)).size).toBe(vectors.length); + + for (const vector of vectors) { + const bytes = readFileSync(new URL(vector.file, directory)); + const commands = vector.commands.map(manifestCommand); + expectBinaryFacts(bytes, vector); + expect([...packOutline(commands).toUint8Array()]).toEqual([...bytes]); + expect(decodeOutline(bytes).commandCount).toBe(commands.length); + } + }); +}); + +function fixtureUrl(format: string): URL { + return new URL(`../../../fixtures/glyph-codec/${format}/`, import.meta.url); +} + +function readJson(url: URL): T { + return JSON.parse(readFileSync(url, "utf8")) as T; +} + +function expectBinaryFacts(bytes: Uint8Array, vector: BinaryVector): void { + expect(bytes.byteLength, `${vector.file} byte length`).toBe(vector.bytes); + expect(createHash("sha256").update(bytes).digest("hex"), `${vector.file} SHA-256`).toBe( + vector.sha256, + ); +} + +function manifestCommand(command: ManifestCommand): OutlineCommand { + switch (command.kind) { + case "move": + case "line": { + const [x, y] = command.coordinates; + if (command.coordinates.length !== 2) throw new Error(`invalid ${command.kind} fixture`); + return { kind: command.kind, x, y }; + } + case "quad": { + const [cx, cy, x, y] = command.coordinates; + if (command.coordinates.length !== 4) throw new Error("invalid quad fixture"); + return { kind: "quad", cx, cy, x, y }; + } + case "cubic": { + const [c1x, c1y, c2x, c2y, x, y] = command.coordinates; + if (command.coordinates.length !== 6) throw new Error("invalid cubic fixture"); + return { kind: "cubic", c1x, c1y, c2x, c2y, x, y }; + } + case "close": + if (command.coordinates.length !== 0) throw new Error("invalid close fixture"); + return { kind: "close" }; + default: + throw new Error(`unknown fixture command ${command.kind}`); + } +} diff --git a/packages/glyph-codec/src/frame.ts b/packages/glyph-codec/src/frame.ts new file mode 100644 index 00000000..9ab76be2 --- /dev/null +++ b/packages/glyph-codec/src/frame.ts @@ -0,0 +1,50 @@ +import { fail } from "./error"; + +const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; +export const FRAME_PREFIX_LENGTH = 8; + +type FrameSpec = { + readonly headerLength: number; + readonly kind: number; + readonly version: number; + readonly headerName: string; + readonly versionName: string; + readonly flagsName: string; +}; + +export function validateFrame(bytes: Uint8Array, spec: FrameSpec): void { + if (bytes.byteLength < spec.headerLength) { + fail( + "header-truncated", + `${spec.headerName} header is truncated: ${bytes.byteLength} of ${spec.headerLength} bytes`, + ); + } + if (MAGIC.some((byte, index) => bytes[index] !== byte)) { + fail("wrong-magic", "wrong glyph-codec magic"); + } + if (bytes[4] !== spec.kind) { + fail("unsupported-kind", `unsupported glyph-codec payload kind ${hex(bytes[4])}`); + } + if (bytes[5] !== spec.version) { + fail("unsupported-version", `unsupported ${spec.versionName} version ${bytes[5]}`); + } + + const flags = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(6, true); + if (flags !== 0) { + fail("unknown-flags", `unknown ${spec.flagsName} flags ${hex(flags, 4)}`); + } +} + +export function writeFrame(bytes: Uint8Array, kind: number, version: number): void { + if (bytes.byteLength < FRAME_PREFIX_LENGTH) { + throw new Error("glyph-codec frame buffer is too short"); + } + bytes.set(MAGIC, 0); + bytes[4] = kind; + bytes[5] = version; + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint16(6, 0, true); +} + +function hex(value: number, width = 2): string { + return `0x${value.toString(16).padStart(width, "0")}`; +} diff --git a/packages/glyph-codec/src/layer-binary.ts b/packages/glyph-codec/src/layer-binary.ts new file mode 100644 index 00000000..7a0ef50c --- /dev/null +++ b/packages/glyph-codec/src/layer-binary.ts @@ -0,0 +1,143 @@ +import { fail } from "./error"; +import { validateFinite } from "./layer-limits"; + +export const MIN_I64 = -(1n << 63n); +export const MAX_I64 = (1n << 63n) - 1n; +export const MAX_U64 = (1n << 64n) - 1n; + +export class Cursor { + readonly #bytes: Uint8Array; + readonly #view: DataView; + offset: number; + end: number; + + constructor(bytes: Uint8Array, offset: number, end: number) { + this.#bytes = bytes; + this.#view = dataView(bytes); + this.offset = offset; + this.end = end; + } + + take(count: number): Uint8Array { + const next = checkedAdd(this.offset, count); + if (next > this.end) + fail( + "truncated", + `glyph-layer body is truncated at byte ${this.offset}: need ${count} bytes, have ${Math.max(0, this.end - this.offset)}`, + ); + const value = this.#bytes.subarray(this.offset, next); + this.offset = next; + return value; + } + + u8(): number { + const offset = this.offset; + this.take(1); + return this.#view.getUint8(offset); + } + u16(): number { + const offset = this.offset; + this.take(2); + return this.#view.getUint16(offset, true); + } + u32(): number { + const offset = this.offset; + this.take(4); + return this.#view.getUint32(offset, true); + } + i64(): bigint { + const offset = this.offset; + this.take(8); + return this.#view.getBigInt64(offset, true); + } + u64(): bigint { + const offset = this.offset; + this.take(8); + return this.#view.getBigUint64(offset, true); + } + f64(): number { + const offset = this.offset; + this.take(8); + return this.#view.getFloat64(offset, true); + } +} + +export class Writer { + readonly #bytes: Uint8Array; + readonly #view: DataView; + offset: number; + + constructor(bytes: Uint8Array, offset: number) { + this.#bytes = bytes; + this.#view = dataView(bytes); + this.offset = offset; + } + + u8(value: number): void { + this.#view.setUint8(this.offset, value); + this.offset += 1; + } + u16(value: number): void { + this.#view.setUint16(this.offset, value, true); + this.offset += 2; + } + u32(value: number): void { + this.#view.setUint32(this.offset, value, true); + this.offset += 4; + } + i64(value: bigint): void { + checkInteger(value, MIN_I64, MAX_I64); + this.#view.setBigInt64(this.offset, value, true); + this.offset += 8; + } + u64(value: bigint): void { + checkInteger(value, 0n, MAX_U64); + this.#view.setBigUint64(this.offset, value, true); + this.offset += 8; + } + f64(value: number): void { + validateFinite(value, "input number", 0); + this.#view.setFloat64(this.offset, value, true); + this.offset += 8; + } + bytes(value: Uint8Array): void { + this.#bytes.set(value, this.offset); + this.offset += value.byteLength; + } +} + +export function checkInteger(value: bigint, minimum: bigint, maximum: bigint): void { + if (value < minimum || value > maximum) + fail("integer-out-of-range", `integer ${value} is outside the encoded range`); +} + +export function requireRange(bytes: Uint8Array, offset: number, count: number): void { + const end = checkedAdd(offset, count); + if (end > bytes.byteLength) + fail( + "truncated", + `glyph-layer body is truncated at byte ${offset}: need ${count} bytes, have ${Math.max(0, bytes.byteLength - offset)}`, + ); +} + +export function checkedAdd(left: number, right: number): number { + const value = left + right; + if (!Number.isSafeInteger(value)) + fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); + return value; +} + +export function checkedMultiply(left: number, right: number): number { + const value = left * right; + if (!Number.isSafeInteger(value)) + fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); + return value; +} + +export function dataView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +export function hex(value: number, width = 2): string { + return `0x${value.toString(16).padStart(width, "0")}`; +} diff --git a/packages/glyph-codec/src/layer-lib.ts b/packages/glyph-codec/src/layer-lib.ts new file mode 100644 index 00000000..5e28bfe7 --- /dev/null +++ b/packages/glyph-codec/src/layer-lib.ts @@ -0,0 +1,255 @@ +import { fail } from "./error"; +import { + checkInteger, + checkedAdd, + Cursor, + hex, + MAX_I64, + MAX_U64, + MIN_I64, + Writer, +} from "./layer-binary"; +import { + checkLayerDepth, + checkLayerLimit, + MAX_LAYER_LIB_VALUES, + validateFinite, +} from "./layer-limits"; +import { compareUtf8, DecodeState, StringPool } from "./layer-strings"; +import type { LayerLibValue } from "./types"; + +export function decodeMap( + cursor: Cursor, + state: DecodeState, + depth: number, +): ReadonlyMap { + checkLayerDepth(depth); + const count = cursor.u32(); + checkLayerLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); + const values = new Map(); + let previous: string | null = null; + for (let index = 0; index < count; index += 1) { + const keyOffset = cursor.offset; + const key = state.requiredString(cursor.u32()); + if (previous !== null && compareUtf8(previous, key) >= 0) + fail( + "noncanonical-map-order", + `dictionary keys are not in canonical UTF-8 order at byte ${keyOffset}`, + ); + previous = key; + values.set(key, decodeLibValue(cursor, state, depth + 1)); + } + return values; +} + +function decodeLibValue(cursor: Cursor, state: DecodeState, depth: number): LayerLibValue { + checkLayerDepth(depth); + state.countLibValue(); + const tagOffset = cursor.offset; + const tag = cursor.u8(); + if (cursor.u8() !== 0 || cursor.u16() !== 0) + fail("non-zero-reserved", `non-zero reserved lib value bytes at index ${state.libValues - 1}`); + switch (tag) { + case 0: + return { kind: "string", value: state.requiredString(cursor.u32()) }; + case 1: + return { kind: "integer", value: cursor.i64() }; + case 2: + return { kind: "unsigned-integer", value: cursor.u64() }; + case 3: { + const value = cursor.f64(); + validateFinite(value, "lib float", state.libValues - 1); + return { kind: "float", value }; + } + case 4: { + const offset = cursor.offset; + const value = cursor.u8(); + if (cursor.take(3).some((byte) => byte !== 0)) + fail( + "non-zero-reserved", + `non-zero reserved lib boolean bytes at index ${state.libValues - 1}`, + ); + if (value !== 0 && value !== 1) + fail("invalid-boolean", `invalid boolean ${value} at byte ${offset}`); + return { kind: "boolean", value: value === 1 }; + } + case 5: { + const count = cursor.u32(); + checkLayerLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); + const value: LayerLibValue[] = []; + for (let index = 0; index < count; index += 1) + value.push(decodeLibValue(cursor, state, depth + 1)); + return { kind: "array", value }; + } + case 6: + return { kind: "dict", value: decodeMap(cursor, state, depth + 1) }; + case 7: + return { kind: "data", value: Uint8Array.from(cursor.take(cursor.u32())) }; + case 8: + return { kind: "date", value: state.requiredString(cursor.u32()) }; + case 9: + return { kind: "uid", value: cursor.u64() }; + default: + fail("unknown-lib-tag", `unknown lib value tag ${hex(tag)} at byte ${tagOffset}`); + } +} + +export function gatherMapStrings( + values: ReadonlyMap, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkLayerDepth(depth); + for (const [key, value] of sortedEntries(values)) { + strings.intern(key); + gatherValueStrings(value, strings, depth + 1, state); + } +} + +function gatherValueStrings( + value: LayerLibValue, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkLayerDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + switch (value.kind) { + case "string": + case "date": + strings.intern(value.value); + break; + case "array": + for (const item of value.value) gatherValueStrings(item, strings, depth + 1, state); + break; + case "dict": + gatherMapStrings(value.value, strings, depth + 1, state); + break; + } +} + +export function mapByteLength( + values: ReadonlyMap, + depth: number, + state: { count: number }, +): number { + checkLayerDepth(depth); + let length = 4; + for (const [, value] of sortedEntries(values)) + length = checkedAdd(length, checkedAdd(4, valueByteLength(value, depth + 1, state))); + return length; +} + +function valueByteLength(value: LayerLibValue, depth: number, state: { count: number }): number { + checkLayerDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + switch (value.kind) { + case "string": + case "date": + return 8; + case "integer": + checkInteger(value.value, MIN_I64, MAX_I64); + return 12; + case "unsigned-integer": + case "uid": + checkInteger(value.value, 0n, MAX_U64); + return 12; + case "float": + validateFinite(value.value, "lib float", state.count - 1); + return 12; + case "boolean": + return 8; + case "array": + return value.value.reduce( + (length, item) => checkedAdd(length, valueByteLength(item, depth + 1, state)), + 8, + ); + case "dict": + return checkedAdd(4, mapByteLength(value.value, depth + 1, state)); + case "data": + return checkedAdd(8, value.value.byteLength); + } +} + +export function encodeMap( + writer: Writer, + values: ReadonlyMap, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkLayerDepth(depth); + writer.u32(values.size); + for (const [key, value] of sortedEntries(values)) { + writer.u32(strings.index(key)); + encodeLibValue(writer, value, strings, depth + 1, state); + } +} + +function encodeLibValue( + writer: Writer, + value: LayerLibValue, + strings: StringPool, + depth: number, + state: { count: number }, +): void { + checkLayerDepth(depth); + state.count = checkedAdd(state.count, 1); + checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); + const tags: Record = { + string: 0, + integer: 1, + "unsigned-integer": 2, + float: 3, + boolean: 4, + array: 5, + dict: 6, + data: 7, + date: 8, + uid: 9, + }; + writer.u8(tags[value.kind]); + writer.u8(0); + writer.u16(0); + switch (value.kind) { + case "string": + case "date": + writer.u32(strings.index(value.value)); + break; + case "integer": + writer.i64(value.value); + break; + case "unsigned-integer": + case "uid": + writer.u64(value.value); + break; + case "float": + writer.f64(value.value); + break; + case "boolean": + writer.u8(value.value ? 1 : 0); + writer.u8(0); + writer.u16(0); + break; + case "array": + writer.u32(value.value.length); + for (const item of value.value) encodeLibValue(writer, item, strings, depth + 1, state); + break; + case "dict": + encodeMap(writer, value.value, strings, depth + 1, state); + break; + case "data": + writer.u32(value.value.byteLength); + writer.bytes(value.value); + break; + } +} + +function sortedEntries( + values: ReadonlyMap, +): ReadonlyArray { + return [...values.entries()].sort((left, right) => compareUtf8(left[0], right[0])); +} diff --git a/packages/glyph-codec/src/layer-limits.ts b/packages/glyph-codec/src/layer-limits.ts new file mode 100644 index 00000000..bf3888ad --- /dev/null +++ b/packages/glyph-codec/src/layer-limits.ts @@ -0,0 +1,31 @@ +import { fail } from "./error"; + +export const MAX_LAYER_CONTOUR_COUNT = 1_000_000; +export const MAX_LAYER_POINT_COUNT = 4_000_000; +export const MAX_LAYER_ENTITY_COUNT = 1_000_000; +export const MAX_LAYER_STRING_COUNT = 4_000_000; +export const MAX_LAYER_STRING_BYTES = 64 * 1024 * 1024; +export const MAX_LAYER_LIB_VALUES = 1_000_000; +export const MAX_LAYER_LIB_DEPTH = 64; +export const MAX_LAYER_PAYLOAD_BYTES = 256 * 1024 * 1024; + +export function checkLayerLimit(field: string, actual: number, limit: number): void { + if (actual > limit) + fail( + "limit-exceeded", + `glyph-layer ${field} exceeds implementation limit ${limit}: got ${actual}`, + ); +} + +export function checkLayerDepth(depth: number): void { + if (depth > MAX_LAYER_LIB_DEPTH) + fail( + "nesting-limit-exceeded", + `layer lib nesting exceeds implementation limit ${MAX_LAYER_LIB_DEPTH}`, + ); +} + +export function validateFinite(value: number, field: string, index: number): void { + if (!Number.isFinite(value)) + fail("non-finite-number", `non-finite ${field} value at index ${index}`); +} diff --git a/packages/glyph-codec/src/layer-strings.ts b/packages/glyph-codec/src/layer-strings.ts new file mode 100644 index 00000000..dbfd50e9 --- /dev/null +++ b/packages/glyph-codec/src/layer-strings.ts @@ -0,0 +1,148 @@ +import { fail } from "./error"; +import { checkedAdd, dataView } from "./layer-binary"; +import { + checkLayerLimit, + MAX_LAYER_LIB_VALUES, + MAX_LAYER_STRING_BYTES, + MAX_LAYER_STRING_COUNT, +} from "./layer-limits"; + +export const NONE_STRING = 0xffff_ffff; +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder("utf-8", { fatal: true }); + +export class StringPool { + readonly #indexes = new Map(); + readonly #values: string[] = []; + readonly #encoded: Uint8Array[] = []; + #byteLength = 0; + + get count(): number { + return this.#values.length; + } + get byteLength(): number { + return this.#byteLength; + } + get encodedValues(): readonly Uint8Array[] { + return this.#encoded; + } + get values(): readonly string[] { + return this.#values; + } + + intern(value: string): number { + const existing = this.#indexes.get(value); + if (existing !== undefined) return existing; + checkLayerLimit("string count", this.#values.length + 1, MAX_LAYER_STRING_COUNT); + const encoded = textEncoder.encode(value); + if (textDecoder.decode(encoded) !== value) + fail("invalid-utf8", "input string is not valid Unicode scalar text"); + this.#byteLength = checkedAdd(this.#byteLength, encoded.byteLength); + checkLayerLimit("string bytes", this.#byteLength, MAX_LAYER_STRING_BYTES); + const index = this.#values.length; + this.#values.push(value); + this.#encoded.push(encoded); + this.#indexes.set(value, index); + return index; + } + + index(value: string): number { + const index = this.#indexes.get(value); + if (index === undefined) throw new Error("strings must be gathered before encoding"); + return index; + } +} + +export class DecodeState { + readonly #strings: readonly string[]; + readonly #validateReferences: boolean; + readonly #identities = new Map>(); + nextString = 0; + pointIndex = 0; + libValues = 0; + + constructor(strings: readonly string[], validateReferences: boolean) { + this.#strings = strings; + this.#validateReferences = validateReferences; + } + + reference(index: number): void { + if (index >= this.#strings.length) + fail("string-reference-out-of-range", `string reference ${index} is out of range`); + if (!this.#validateReferences) return; + if (index > this.nextString) + fail( + "noncanonical-string-reference", + `string reference ${index} is out of canonical first-use order; expected at most ${this.nextString}`, + ); + if (index === this.nextString) this.nextString += 1; + } + + requiredString(index: number): string { + this.reference(index); + return this.#strings[index]; + } + + optionalString(index: number): string | null { + return index === NONE_STRING ? null : this.requiredString(index); + } + + unique(kind: string, index: number, value: string): void { + let identities = this.#identities.get(kind); + if (!identities) { + identities = new Set(); + this.#identities.set(kind, identities); + } + if (identities.has(value)) + fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); + identities.add(value); + } + + countLibValue(): void { + this.libValues = checkedAdd(this.libValues, 1); + checkLayerLimit("lib value count", this.libValues, MAX_LAYER_LIB_VALUES); + } +} + +export function validateStrings( + bytes: Uint8Array, + count: number, + expectedBytes: number, + offsetsOffset: number, + valuesOffset: number, +): readonly string[] { + const view = dataView(bytes); + if (view.getUint32(offsetsOffset, true) !== 0) + fail("invalid-string-offsets", "non-canonical string offsets at index 0"); + const strings: string[] = []; + const seen = new Set(); + let previous = 0; + for (let index = 0; index < count; index += 1) { + const end = view.getUint32(offsetsOffset + (index + 1) * 4, true); + if (end < previous || end > expectedBytes) + fail("invalid-string-offsets", `non-canonical string offsets at index ${index + 1}`); + let value: string; + try { + value = textDecoder.decode(bytes.subarray(valuesOffset + previous, valuesOffset + end)); + } catch { + fail("invalid-utf8", `invalid UTF-8 in string ${index}`); + } + if (seen.has(value)) fail("duplicate-string", `duplicate string-table value at index ${index}`); + seen.add(value); + strings.push(value); + previous = end; + } + if (previous !== expectedBytes) + fail("invalid-string-offsets", `non-canonical string offsets at index ${count}`); + return strings; +} + +export function compareUtf8(left: string, right: string): number { + const leftBytes = textEncoder.encode(left); + const rightBytes = textEncoder.encode(right); + const count = Math.min(leftBytes.length, rightBytes.length); + for (let index = 0; index < count; index += 1) { + if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; + } + return leftBytes.length - rightBytes.length; +} diff --git a/packages/glyph-codec/src/layer.ts b/packages/glyph-codec/src/layer.ts index 6465fef4..c70ed39e 100644 --- a/packages/glyph-codec/src/layer.ts +++ b/packages/glyph-codec/src/layer.ts @@ -1,4 +1,26 @@ import { fail } from "./error"; +import { validateFrame, writeFrame } from "./frame"; +import { + checkedAdd, + checkedMultiply, + Cursor, + dataView, + hex, + requireRange, + Writer, +} from "./layer-binary"; +import { decodeMap, encodeMap, gatherMapStrings, mapByteLength } from "./layer-lib"; +import { + checkLayerLimit as checkLimit, + MAX_LAYER_CONTOUR_COUNT, + MAX_LAYER_ENTITY_COUNT, + MAX_LAYER_PAYLOAD_BYTES, + MAX_LAYER_POINT_COUNT, + MAX_LAYER_STRING_BYTES, + MAX_LAYER_STRING_COUNT, + validateFinite, +} from "./layer-limits"; +import { DecodeState, NONE_STRING, StringPool, validateStrings } from "./layer-strings"; import type { GlyphLayer, LayerAnchor, @@ -12,29 +34,25 @@ import type { } from "./types"; export { GlyphCodecError } from "./error"; +export { + MAX_LAYER_CONTOUR_COUNT, + MAX_LAYER_ENTITY_COUNT, + MAX_LAYER_LIB_DEPTH, + MAX_LAYER_LIB_VALUES, + MAX_LAYER_PAYLOAD_BYTES, + MAX_LAYER_POINT_COUNT, + MAX_LAYER_STRING_BYTES, + MAX_LAYER_STRING_COUNT, +} from "./layer-limits"; -const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; const LAYER_KIND = 0x02; const LAYER_VERSION = 0x01; const HEADER_LENGTH = 72; -const NONE_STRING = 0xffff_ffff; const CONTOUR_LENGTH = 12; const POINT_LENGTH = 24; const COMPONENT_LENGTH = 88; const ANCHOR_LENGTH = 24; const GUIDELINE_LENGTH = 40; -const MIN_I64 = -(1n << 63n); -const MAX_I64 = (1n << 63n) - 1n; -const MAX_U64 = (1n << 64n) - 1n; - -export const MAX_LAYER_CONTOUR_COUNT = 1_000_000; -export const MAX_LAYER_POINT_COUNT = 4_000_000; -export const MAX_LAYER_ENTITY_COUNT = 1_000_000; -export const MAX_LAYER_STRING_COUNT = 4_000_000; -export const MAX_LAYER_STRING_BYTES = 64 * 1024 * 1024; -export const MAX_LAYER_LIB_VALUES = 1_000_000; -export const MAX_LAYER_LIB_DEPTH = 64; -export const MAX_LAYER_PAYLOAD_BYTES = 256 * 1024 * 1024; type Header = { readonly strings: readonly string[]; @@ -59,6 +77,7 @@ type Header = { /** One contour backed by validated packed bytes. */ export class LayerContourView { readonly #bytes: Uint8Array; + readonly #view: DataView; readonly #strings: readonly string[]; readonly #pointsOffset: number; readonly id: string; @@ -67,6 +86,7 @@ export class LayerContourView { constructor( bytes: Uint8Array, + view: DataView, strings: readonly string[], id: string, closed: boolean, @@ -74,6 +94,7 @@ export class LayerContourView { pointsOffset: number, ) { this.#bytes = bytes; + this.#view = view; this.#strings = strings; this.#pointsOffset = pointsOffset; this.id = id; @@ -82,15 +103,14 @@ export class LayerContourView { } *points(): IterableIterator { - const view = dataView(this.#bytes); let offset = this.#pointsOffset; for (let index = 0; index < this.pointCount; index += 1) { yield { - id: this.#strings[view.getUint32(offset, true)], + id: this.#strings[this.#view.getUint32(offset, true)], type: pointTypeFromByte(this.#bytes[offset + 4], index), smooth: (this.#bytes[offset + 5] & 1) !== 0, - x: view.getFloat64(offset + 8, true), - y: view.getFloat64(offset + 16, true), + x: this.#view.getFloat64(offset + 8, true), + y: this.#view.getFloat64(offset + 16, true), }; offset += POINT_LENGTH; } @@ -109,10 +129,12 @@ export class LayerContourView { */ export class PackedGlyphLayer { readonly #bytes: Uint8Array; + readonly #view: DataView; readonly #header: Header; private constructor(bytes: Uint8Array, header: Header) { this.#bytes = bytes; + this.#view = dataView(bytes); this.#header = header; } @@ -172,16 +194,16 @@ export class PackedGlyphLayer { } *contours(): IterableIterator { - const view = dataView(this.#bytes); let offset = this.#header.contoursOffset; for (let index = 0; index < this.#header.contourCount; index += 1) { - const pointCount = view.getUint32(offset + 4, true); + const pointCount = this.#view.getUint32(offset + 4, true); const pointsOffset = offset + CONTOUR_LENGTH; yield new LayerContourView( this.#bytes, + this.#view, this.#header.strings, - this.#header.strings[view.getUint32(offset, true)], - (view.getUint32(offset + 8, true) & 1) !== 0, + this.#header.strings[this.#view.getUint32(offset, true)], + (this.#view.getUint32(offset + 8, true) & 1) !== 0, pointCount, pointsOffset, ); @@ -190,45 +212,42 @@ export class PackedGlyphLayer { } *components(): IterableIterator { - const view = dataView(this.#bytes); let offset = this.#header.componentsOffset; for (let index = 0; index < this.#header.componentCount; index += 1) { yield { - id: this.#header.strings[view.getUint32(offset, true)], - baseGlyphId: this.#header.strings[view.getUint32(offset + 4, true)], - baseGlyphName: this.#header.strings[view.getUint32(offset + 8, true)], - transform: readTransform(view, offset + 16), + id: this.#header.strings[this.#view.getUint32(offset, true)], + baseGlyphId: this.#header.strings[this.#view.getUint32(offset + 4, true)], + baseGlyphName: this.#header.strings[this.#view.getUint32(offset + 8, true)], + transform: readTransform(this.#view, offset + 16), }; offset += COMPONENT_LENGTH; } } *anchors(): IterableIterator { - const view = dataView(this.#bytes); let offset = this.#header.anchorsOffset; for (let index = 0; index < this.#header.anchorCount; index += 1) { yield { - id: this.#header.strings[view.getUint32(offset, true)], - name: optionalString(this.#header.strings, view.getUint32(offset + 4, true)), - x: view.getFloat64(offset + 8, true), - y: view.getFloat64(offset + 16, true), + id: this.#header.strings[this.#view.getUint32(offset, true)], + name: optionalString(this.#header.strings, this.#view.getUint32(offset + 4, true)), + x: this.#view.getFloat64(offset + 8, true), + y: this.#view.getFloat64(offset + 16, true), }; offset += ANCHOR_LENGTH; } } *guidelines(): IterableIterator { - const view = dataView(this.#bytes); let offset = this.#header.guidelinesOffset; for (let index = 0; index < this.#header.guidelineCount; index += 1) { - const flags = view.getUint32(offset + 12, true); + const flags = this.#view.getUint32(offset + 12, true); yield { - id: this.#header.strings[view.getUint32(offset, true)], - name: optionalString(this.#header.strings, view.getUint32(offset + 4, true)), - color: optionalString(this.#header.strings, view.getUint32(offset + 8, true)), - x: (flags & 1) === 0 ? null : view.getFloat64(offset + 16, true), - y: (flags & 2) === 0 ? null : view.getFloat64(offset + 24, true), - angle: (flags & 4) === 0 ? null : view.getFloat64(offset + 32, true), + id: this.#header.strings[this.#view.getUint32(offset, true)], + name: optionalString(this.#header.strings, this.#view.getUint32(offset + 4, true)), + color: optionalString(this.#header.strings, this.#view.getUint32(offset + 8, true)), + x: (flags & 1) === 0 ? null : this.#view.getFloat64(offset + 16, true), + y: (flags & 2) === 0 ? null : this.#view.getFloat64(offset + 24, true), + angle: (flags & 4) === 0 ? null : this.#view.getFloat64(offset + 32, true), }; offset += GUIDELINE_LENGTH; } @@ -263,6 +282,7 @@ export function decodeLayer(data: Uint8Array): PackedGlyphLayer { } function encode(layer: GlyphLayer): readonly [Uint8Array, Header] { + validateLayerInput(layer); checkLimit("contour count", layer.contours.length, MAX_LAYER_CONTOUR_COUNT); checkLimit("component count", layer.components.length, MAX_LAYER_ENTITY_COUNT); checkLimit("anchor count", layer.anchors.length, MAX_LAYER_ENTITY_COUNT); @@ -307,24 +327,30 @@ function encode(layer: GlyphLayer): readonly [Uint8Array, Header] { ), 0, ); - const byteLength = [ - HEADER_LENGTH, - checkedMultiply(strings.count + 1, 4), + const stringOffsetsBytes = checkedMultiply(strings.count + 1, 4); + const contoursOffset = checkedAdd( + checkedAdd(HEADER_LENGTH, stringOffsetsBytes), strings.byteLength, - contoursBytes, + ); + const componentsOffset = checkedAdd(contoursOffset, contoursBytes); + const anchorsOffset = checkedAdd( + componentsOffset, checkedMultiply(layer.components.length, COMPONENT_LENGTH), + ); + const guidelinesOffset = checkedAdd( + anchorsOffset, checkedMultiply(layer.anchors.length, ANCHOR_LENGTH), + ); + const libOffset = checkedAdd( + guidelinesOffset, checkedMultiply(layer.guidelines.length, GUIDELINE_LENGTH), - libBytes, - ].reduce(checkedAdd, 0); + ); + const byteLength = checkedAdd(libOffset, libBytes); checkLimit("payload byte length", byteLength, MAX_LAYER_PAYLOAD_BYTES); const bytes = new Uint8Array(byteLength); const view = dataView(bytes); - bytes.set(MAGIC, 0); - bytes[4] = LAYER_KIND; - bytes[5] = LAYER_VERSION; - view.setUint16(6, 0, true); + writeFrame(bytes, LAYER_KIND, LAYER_VERSION); view.setUint32(8, byteLength, true); view.setUint32(12, strings.count, true); view.setUint32(16, strings.byteLength, true); @@ -392,28 +418,72 @@ function encode(layer: GlyphLayer): readonly [Uint8Array, Header] { if (writer.offset !== byteLength) fail("length-mismatch", "internal glyph-layer encoder length mismatch"); - return [bytes, validate(bytes)]; + return [ + bytes, + { + strings: strings.values, + stringCount: strings.count, + contourCount: layer.contours.length, + pointCount, + componentCount: layer.components.length, + anchorCount: layer.anchors.length, + guidelineCount: layer.guidelines.length, + idIndex: strings.index(layer.id), + sourceIdIndex: strings.index(layer.sourceId), + width: layer.width, + height: layer.height, + contoursOffset, + componentsOffset, + anchorsOffset, + guidelinesOffset, + libOffset, + libEnd: byteLength, + }, + ]; } -function validate(bytes: Uint8Array): Header { - if (bytes.byteLength < HEADER_LENGTH) { - fail( - "header-truncated", - `glyph-layer header is truncated: ${bytes.byteLength} of ${HEADER_LENGTH} bytes`, - ); +function validateLayerInput(layer: GlyphLayer): void { + validateFinite(layer.width, "width", 0); + if (layer.height !== null) validateFinite(layer.height, "height", 0); + + const identities = new Map>(); + const unique = (kind: string, index: number, id: string): void => { + let values = identities.get(kind); + if (!values) { + values = new Set(); + identities.set(kind, values); + } + if (values.has(id)) fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); + values.add(id); + }; + + let pointIndex = 0; + for (const [contourIndex, contour] of layer.contours.entries()) { + unique("contour", contourIndex, contour.id); + for (const point of contour.points) { + unique("point", pointIndex, point.id); + pointIndex += 1; + } } + for (const [index, component] of layer.components.entries()) + unique("component", index, component.id); + for (const [index, anchor] of layer.anchors.entries()) unique("anchor", index, anchor.id); + for (const [index, guideline] of layer.guidelines.entries()) + unique("guideline", index, guideline.id); +} + +function validate(bytes: Uint8Array): Header { + validateFrame(bytes, { + headerLength: HEADER_LENGTH, + kind: LAYER_KIND, + version: LAYER_VERSION, + headerName: "glyph-layer", + versionName: "glyph-layer", + flagsName: "glyph-layer frame", + }); checkLimit("payload byte length", bytes.byteLength, MAX_LAYER_PAYLOAD_BYTES); - if (MAGIC.some((byte, index) => bytes[index] !== byte)) - fail("wrong-magic", "wrong glyph-codec magic"); - if (bytes[4] !== LAYER_KIND) - fail("unsupported-kind", `unsupported glyph-codec payload kind ${hex(bytes[4])}`); - if (bytes[5] !== LAYER_VERSION) - fail("unsupported-version", `unsupported glyph-layer version ${bytes[5]}`); const view = dataView(bytes); - const frameFlags = view.getUint16(6, true); - if (frameFlags !== 0) - fail("unknown-flags", `unknown glyph-layer frame flags ${hex(frameFlags, 4)}`); const declaredLength = view.getUint32(8, true); if (declaredLength !== bytes.byteLength) fail( @@ -454,7 +524,7 @@ function validate(bytes: Uint8Array): Header { const valuesOffset = checkedAdd(HEADER_LENGTH, offsetsBytes); const stringsEnd = checkedAdd(valuesOffset, stringBytes); requireRange(bytes, HEADER_LENGTH, checkedAdd(offsetsBytes, stringBytes)); - const strings = validateStrings(bytes, stringCount, stringBytes, valuesOffset); + const strings = validateStrings(bytes, stringCount, stringBytes, HEADER_LENGTH, valuesOffset); const state = new DecodeState(strings, true); state.reference(idIndex); state.reference(sourceIdIndex); @@ -571,474 +641,6 @@ function validate(bytes: Uint8Array): Header { }; } -class StringPool { - readonly #indexes = new Map(); - readonly #values: string[] = []; - readonly #encoded: Uint8Array[] = []; - #byteLength = 0; - - get count(): number { - return this.#values.length; - } - get byteLength(): number { - return this.#byteLength; - } - get encodedValues(): readonly Uint8Array[] { - return this.#encoded; - } - - intern(value: string): number { - const existing = this.#indexes.get(value); - if (existing !== undefined) return existing; - checkLimit("string count", this.#values.length + 1, MAX_LAYER_STRING_COUNT); - const encoded = textEncoder.encode(value); - this.#byteLength = checkedAdd(this.#byteLength, encoded.byteLength); - checkLimit("string bytes", this.#byteLength, MAX_LAYER_STRING_BYTES); - const index = this.#values.length; - this.#values.push(value); - this.#encoded.push(encoded); - this.#indexes.set(value, index); - return index; - } - - index(value: string): number { - const index = this.#indexes.get(value); - if (index === undefined) throw new Error("strings must be gathered before encoding"); - return index; - } -} - -class DecodeState { - readonly #strings: readonly string[]; - readonly #validateReferences: boolean; - readonly #identities = new Map>(); - nextString = 0; - pointIndex = 0; - libValues = 0; - - constructor(strings: readonly string[], validateReferences: boolean) { - this.#strings = strings; - this.#validateReferences = validateReferences; - } - - reference(index: number): void { - if (index >= this.#strings.length) - fail("string-reference-out-of-range", `string reference ${index} is out of range`); - if (!this.#validateReferences) return; - if (index > this.nextString) - fail( - "noncanonical-string-reference", - `string reference ${index} is out of canonical first-use order; expected at most ${this.nextString}`, - ); - if (index === this.nextString) this.nextString += 1; - } - - requiredString(index: number): string { - this.reference(index); - return this.#strings[index]; - } - - optionalString(index: number): string | null { - return index === NONE_STRING ? null : this.requiredString(index); - } - - unique(kind: string, index: number, value: string): void { - let identities = this.#identities.get(kind); - if (!identities) { - identities = new Set(); - this.#identities.set(kind, identities); - } - if (identities.has(value)) - fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); - identities.add(value); - } - - countLibValue(): void { - this.libValues = checkedAdd(this.libValues, 1); - checkLimit("lib value count", this.libValues, MAX_LAYER_LIB_VALUES); - } -} - -class Cursor { - readonly #bytes: Uint8Array; - readonly #view: DataView; - offset: number; - end: number; - - constructor(bytes: Uint8Array, offset: number, end: number) { - this.#bytes = bytes; - this.#view = dataView(bytes); - this.offset = offset; - this.end = end; - } - - take(count: number): Uint8Array { - const next = checkedAdd(this.offset, count); - if (next > this.end) - fail( - "truncated", - `glyph-layer body is truncated at byte ${this.offset}: need ${count} bytes, have ${Math.max(0, this.end - this.offset)}`, - ); - const value = this.#bytes.subarray(this.offset, next); - this.offset = next; - return value; - } - - u8(): number { - const offset = this.offset; - this.take(1); - return this.#view.getUint8(offset); - } - u16(): number { - const offset = this.offset; - this.take(2); - return this.#view.getUint16(offset, true); - } - u32(): number { - const offset = this.offset; - this.take(4); - return this.#view.getUint32(offset, true); - } - i64(): bigint { - const offset = this.offset; - this.take(8); - return this.#view.getBigInt64(offset, true); - } - u64(): bigint { - const offset = this.offset; - this.take(8); - return this.#view.getBigUint64(offset, true); - } - f64(): number { - const offset = this.offset; - this.take(8); - return this.#view.getFloat64(offset, true); - } -} - -class Writer { - readonly #bytes: Uint8Array; - readonly #view: DataView; - offset: number; - - constructor(bytes: Uint8Array, offset: number) { - this.#bytes = bytes; - this.#view = dataView(bytes); - this.offset = offset; - } - - u8(value: number): void { - this.#view.setUint8(this.offset, value); - this.offset += 1; - } - u16(value: number): void { - this.#view.setUint16(this.offset, value, true); - this.offset += 2; - } - u32(value: number): void { - this.#view.setUint32(this.offset, value, true); - this.offset += 4; - } - i64(value: bigint): void { - checkInteger(value, MIN_I64, MAX_I64); - this.#view.setBigInt64(this.offset, value, true); - this.offset += 8; - } - u64(value: bigint): void { - checkInteger(value, 0n, MAX_U64); - this.#view.setBigUint64(this.offset, value, true); - this.offset += 8; - } - f64(value: number): void { - this.#view.setFloat64(this.offset, value, true); - this.offset += 8; - } - bytes(value: Uint8Array): void { - this.#bytes.set(value, this.offset); - this.offset += value.byteLength; - } -} - -function decodeMap( - cursor: Cursor, - state: DecodeState, - depth: number, -): ReadonlyMap { - checkDepth(depth); - const count = cursor.u32(); - checkLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); - const values = new Map(); - let previous: string | null = null; - for (let index = 0; index < count; index += 1) { - const keyOffset = cursor.offset; - const key = state.requiredString(cursor.u32()); - if (previous !== null && compareUtf8(previous, key) >= 0) - fail( - "noncanonical-map-order", - `dictionary keys are not in canonical UTF-8 order at byte ${keyOffset}`, - ); - previous = key; - values.set(key, decodeLibValue(cursor, state, depth + 1)); - } - return values; -} - -function decodeLibValue(cursor: Cursor, state: DecodeState, depth: number): LayerLibValue { - checkDepth(depth); - state.countLibValue(); - const tagOffset = cursor.offset; - const tag = cursor.u8(); - if (cursor.u8() !== 0 || cursor.u16() !== 0) - fail("non-zero-reserved", `non-zero reserved lib value bytes at index ${state.libValues - 1}`); - switch (tag) { - case 0: - return { kind: "string", value: state.requiredString(cursor.u32()) }; - case 1: - return { kind: "integer", value: cursor.i64() }; - case 2: - return { kind: "unsigned-integer", value: cursor.u64() }; - case 3: { - const value = cursor.f64(); - validateFinite(value, "lib float", state.libValues - 1); - return { kind: "float", value }; - } - case 4: { - const offset = cursor.offset; - const value = cursor.u8(); - if (cursor.take(3).some((byte) => byte !== 0)) - fail( - "non-zero-reserved", - `non-zero reserved lib boolean bytes at index ${state.libValues - 1}`, - ); - if (value !== 0 && value !== 1) - fail("invalid-boolean", `invalid boolean ${value} at byte ${offset}`); - return { kind: "boolean", value: value === 1 }; - } - case 5: { - const count = cursor.u32(); - checkLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); - const value: LayerLibValue[] = []; - for (let index = 0; index < count; index += 1) - value.push(decodeLibValue(cursor, state, depth + 1)); - return { kind: "array", value }; - } - case 6: - return { kind: "dict", value: decodeMap(cursor, state, depth + 1) }; - case 7: - return { kind: "data", value: Uint8Array.from(cursor.take(cursor.u32())) }; - case 8: - return { kind: "date", value: state.requiredString(cursor.u32()) }; - case 9: - return { kind: "uid", value: cursor.u64() }; - default: - fail("unknown-lib-tag", `unknown lib value tag ${hex(tag)} at byte ${tagOffset}`); - } -} - -function gatherMapStrings( - values: ReadonlyMap, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkDepth(depth); - for (const [key, value] of sortedEntries(values)) { - strings.intern(key); - gatherValueStrings(value, strings, depth + 1, state); - } -} - -function gatherValueStrings( - value: LayerLibValue, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - switch (value.kind) { - case "string": - case "date": - strings.intern(value.value); - break; - case "array": - for (const item of value.value) gatherValueStrings(item, strings, depth + 1, state); - break; - case "dict": - gatherMapStrings(value.value, strings, depth + 1, state); - break; - } -} - -function mapByteLength( - values: ReadonlyMap, - depth: number, - state: { count: number }, -): number { - checkDepth(depth); - let length = 4; - for (const [, value] of sortedEntries(values)) - length = checkedAdd(length, checkedAdd(4, valueByteLength(value, depth + 1, state))); - return length; -} - -function valueByteLength(value: LayerLibValue, depth: number, state: { count: number }): number { - checkDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - switch (value.kind) { - case "string": - case "date": - return 8; - case "integer": - checkInteger(value.value, MIN_I64, MAX_I64); - return 12; - case "unsigned-integer": - case "uid": - checkInteger(value.value, 0n, MAX_U64); - return 12; - case "float": - return 12; - case "boolean": - return 8; - case "array": - return value.value.reduce( - (length, item) => checkedAdd(length, valueByteLength(item, depth + 1, state)), - 8, - ); - case "dict": - return checkedAdd(4, mapByteLength(value.value, depth + 1, state)); - case "data": - return checkedAdd(8, value.value.byteLength); - } -} - -function encodeMap( - writer: Writer, - values: ReadonlyMap, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkDepth(depth); - writer.u32(values.size); - for (const [key, value] of sortedEntries(values)) { - writer.u32(strings.index(key)); - encodeLibValue(writer, value, strings, depth + 1, state); - } -} - -function encodeLibValue( - writer: Writer, - value: LayerLibValue, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - const tags: Record = { - string: 0, - integer: 1, - "unsigned-integer": 2, - float: 3, - boolean: 4, - array: 5, - dict: 6, - data: 7, - date: 8, - uid: 9, - }; - writer.u8(tags[value.kind]); - writer.u8(0); - writer.u16(0); - switch (value.kind) { - case "string": - case "date": - writer.u32(strings.index(value.value)); - break; - case "integer": - writer.i64(value.value); - break; - case "unsigned-integer": - case "uid": - writer.u64(value.value); - break; - case "float": - writer.f64(value.value); - break; - case "boolean": - writer.u8(value.value ? 1 : 0); - writer.u8(0); - writer.u16(0); - break; - case "array": - writer.u32(value.value.length); - for (const item of value.value) encodeLibValue(writer, item, strings, depth + 1, state); - break; - case "dict": - encodeMap(writer, value.value, strings, depth + 1, state); - break; - case "data": - writer.u32(value.value.byteLength); - writer.bytes(value.value); - break; - } -} - -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder("utf-8", { fatal: true }); - -function validateStrings( - bytes: Uint8Array, - count: number, - expectedBytes: number, - valuesOffset: number, -): readonly string[] { - const view = dataView(bytes); - if (view.getUint32(HEADER_LENGTH, true) !== 0) - fail("invalid-string-offsets", "non-canonical string offsets at index 0"); - const strings: string[] = []; - const seen = new Set(); - let previous = 0; - for (let index = 0; index < count; index += 1) { - const end = view.getUint32(HEADER_LENGTH + (index + 1) * 4, true); - if (end < previous || end > expectedBytes) - fail("invalid-string-offsets", `non-canonical string offsets at index ${index + 1}`); - let value: string; - try { - value = textDecoder.decode(bytes.subarray(valuesOffset + previous, valuesOffset + end)); - } catch { - fail("invalid-utf8", `invalid UTF-8 in string ${index}`); - } - if (seen.has(value)) fail("duplicate-string", `duplicate string-table value at index ${index}`); - seen.add(value); - strings.push(value); - previous = end; - } - if (previous !== expectedBytes) - fail("invalid-string-offsets", `non-canonical string offsets at index ${count}`); - return strings; -} - -function sortedEntries( - values: ReadonlyMap, -): ReadonlyArray { - return [...values.entries()].sort((left, right) => compareUtf8(left[0], right[0])); -} - -function compareUtf8(left: string, right: string): number { - const leftBytes = textEncoder.encode(left); - const rightBytes = textEncoder.encode(right); - const count = Math.min(leftBytes.length, rightBytes.length); - for (let index = 0; index < count; index += 1) { - if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; - } - return leftBytes.length - rightBytes.length; -} - function transformValues(transform: LayerTransform): readonly number[] { return [ transform.translateX, @@ -1076,6 +678,8 @@ function pointTypeByte(type: LayerPointType): number { return 1; case "qcurve": return 2; + default: + fail("unknown-point-type", `unknown input point type ${String(type)}`); } } @@ -1096,11 +700,6 @@ function optionalString(strings: readonly string[], index: number): string | nul return index === NONE_STRING ? null : strings[index]; } -function validateFinite(value: number, field: string, index: number): void { - if (!Number.isFinite(value)) - fail("non-finite-number", `non-finite ${field} value at index ${index}`); -} - function validateAbsent(view: DataView, offset: number, field: string, index: number): void { if (view.getBigUint64(offset, true) !== 0n) fail( @@ -1108,55 +707,3 @@ function validateAbsent(view: DataView, offset: number, field: string, index: nu `absent ${field} value at index ${index} is not canonical positive zero`, ); } - -function checkInteger(value: bigint, minimum: bigint, maximum: bigint): void { - if (value < minimum || value > maximum) - fail("integer-out-of-range", `integer ${value} is outside the encoded range`); -} - -function checkDepth(depth: number): void { - if (depth > MAX_LAYER_LIB_DEPTH) - fail( - "nesting-limit-exceeded", - `layer lib nesting exceeds implementation limit ${MAX_LAYER_LIB_DEPTH}`, - ); -} - -function checkLimit(field: string, actual: number, limit: number): void { - if (actual > limit) - fail( - "limit-exceeded", - `glyph-layer ${field} exceeds implementation limit ${limit}: got ${actual}`, - ); -} - -function requireRange(bytes: Uint8Array, offset: number, count: number): void { - const end = checkedAdd(offset, count); - if (end > bytes.byteLength) - fail( - "truncated", - `glyph-layer body is truncated at byte ${offset}: need ${count} bytes, have ${Math.max(0, bytes.byteLength - offset)}`, - ); -} - -function checkedAdd(left: number, right: number): number { - const value = left + right; - if (!Number.isSafeInteger(value)) - fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); - return value; -} - -function checkedMultiply(left: number, right: number): number { - const value = left * right; - if (!Number.isSafeInteger(value)) - fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); - return value; -} - -function dataView(bytes: Uint8Array): DataView { - return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); -} - -function hex(value: number, width = 2): string { - return `0x${value.toString(16).padStart(width, "0")}`; -} diff --git a/packages/glyph-codec/src/outline.ts b/packages/glyph-codec/src/outline.ts index b5486a2c..ccb5a702 100644 --- a/packages/glyph-codec/src/outline.ts +++ b/packages/glyph-codec/src/outline.ts @@ -1,9 +1,9 @@ import { fail } from "./error"; +import { validateFrame, writeFrame } from "./frame"; import type { OutlineCommand } from "./types"; export { GlyphCodecError } from "./error"; -const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; const OUTLINE_KIND = 0x01; const OUTLINE_VERSION = 0x01; const HEADER_LENGTH = 16; @@ -63,11 +63,8 @@ export class PackedGlyphOutline implements Iterable { checkLimit("payload byte length", byteLength, MAX_PAYLOAD_BYTES); const bytes = new Uint8Array(byteLength); - bytes.set(MAGIC, 0); - bytes[4] = OUTLINE_KIND; - bytes[5] = OUTLINE_VERSION; + writeFrame(bytes, OUTLINE_KIND, OUTLINE_VERSION); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - view.setUint16(6, 0, true); view.setUint32(8, commands.length, true); view.setUint32(12, coordinateCount, true); @@ -176,29 +173,17 @@ export function decodeOutline(data: Uint8Array): PackedGlyphOutline { } function validate(bytes: Uint8Array): Header { - if (bytes.byteLength < HEADER_LENGTH) { - fail( - "header-truncated", - `outline header is truncated: ${bytes.byteLength} of ${HEADER_LENGTH} bytes`, - ); - } + validateFrame(bytes, { + headerLength: HEADER_LENGTH, + kind: OUTLINE_KIND, + version: OUTLINE_VERSION, + headerName: "outline", + versionName: "glyph-outline", + flagsName: "glyph-outline", + }); checkLimit("payload byte length", bytes.byteLength, MAX_PAYLOAD_BYTES); - if (MAGIC.some((byte, index) => bytes[index] !== byte)) { - fail("wrong-magic", "wrong glyph-codec magic"); - } - if (bytes[4] !== OUTLINE_KIND) { - fail("unsupported-kind", `unsupported glyph-codec payload kind ${hex(bytes[4])}`); - } - if (bytes[5] !== OUTLINE_VERSION) { - fail("unsupported-version", `unsupported glyph-outline version ${bytes[5]}`); - } - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const flags = view.getUint16(6, true); - if (flags !== 0) { - fail("unknown-flags", `unknown glyph-outline flags ${hex(flags, 4)}`); - } const commandCount = view.getUint32(8, true); const coordinateCount = view.getUint32(12, true); From c4007d01cb87ed8ea64f6b00fbba001282abf48c Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 08:45:10 +0000 Subject: [PATCH 10/29] Cache canonical layer codec metadata --- crates/shift-glyph-codec/SPECIFICATION.md | 9 +- crates/shift-glyph-codec/src/layer.rs | 226 ++++++++++++++++-- crates/shift-glyph-codec/src/lib.rs | 16 +- crates/shift-glyph-codec/tests/fixtures.rs | 10 +- .../glyph-codec/layer-v1/guideline-flags.bin | Bin 0 -> 584 bytes fixtures/glyph-codec/layer-v1/lib-only.bin | Bin 0 -> 223 bytes fixtures/glyph-codec/layer-v1/vectors.json | 12 + fixtures/glyph-codec/outline-v1/vectors.json | 158 +++++++----- packages/glyph-codec/README.md | 2 +- packages/glyph-codec/src/fixtures.test.ts | 9 +- packages/glyph-codec/src/layer-lib.ts | 29 +-- packages/glyph-codec/src/layer-strings.ts | 4 +- packages/glyph-codec/src/layer.test.ts | 2 +- packages/glyph-codec/src/layer.ts | 12 +- packages/glyph-codec/src/types.ts | 6 +- 15 files changed, 381 insertions(+), 114 deletions(-) create mode 100644 fixtures/glyph-codec/layer-v1/guideline-flags.bin create mode 100644 fixtures/glyph-codec/layer-v1/lib-only.bin diff --git a/crates/shift-glyph-codec/SPECIFICATION.md b/crates/shift-glyph-codec/SPECIFICATION.md index 1c2405af..5d0fb414 100644 --- a/crates/shift-glyph-codec/SPECIFICATION.md +++ b/crates/shift-glyph-codec/SPECIFICATION.md @@ -316,10 +316,11 @@ time without constructing a complete font or retaining every editable layer. ## Implementation structure Both implementations share one frame parser/writer across payload kinds. Rust -keeps outline and layer codecs in separate modules. TypeScript separates frame, -binary cursor/writer, string-table, lib-value, and limit handling from the -layer views. Successful TypeScript encoding constructs its validated offsets as -it writes; only externally supplied bytes run the complete decoder. +keeps outline and layer codecs in separate modules and caches validated layer +offsets in `PackedGlyphLayer`. TypeScript separates frame, binary cursor/writer, +string-table, lib-value, and limit handling from the layer views. Successful +encoding validates writer input and constructs its offsets while writing; only +externally supplied bytes run the complete decoder in release builds. ## Golden vectors diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs index 4b24f104..f325e574 100644 --- a/crates/shift-glyph-codec/src/layer.rs +++ b/crates/shift-glyph-codec/src/layer.rs @@ -346,25 +346,28 @@ impl From for LayerCodecError { } /// Owned canonical `shift.glyph-layer.v1` bytes. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PackedGlyphLayer(Vec); +#[derive(Clone, Debug)] +pub struct PackedGlyphLayer { + bytes: Vec, + header: LayerHeader, +} impl PackedGlyphLayer { pub fn from_bytes(bytes: Vec) -> Result { - decode_layer(&bytes)?; - Ok(Self(bytes)) + let header = LayerHeader::from_view(decode_layer(&bytes)?); + Ok(Self { bytes, header }) } pub fn as_bytes(&self) -> &[u8] { - &self.0 + &self.bytes } pub fn into_bytes(self) -> Vec { - self.0 + self.bytes } pub fn view(&self) -> GlyphLayerView<'_> { - decode_layer(&self.0).expect("PackedGlyphLayer invariant violated") + self.header.view(&self.bytes) } pub fn unpack(&self) -> GlyphLayer { @@ -372,6 +375,14 @@ impl PackedGlyphLayer { } } +impl PartialEq for PackedGlyphLayer { + fn eq(&self, other: &Self) -> bool { + self.bytes == other.bytes + } +} + +impl Eq for PackedGlyphLayer {} + #[derive(Clone, Copy, Debug)] struct Strings<'a> { bytes: &'a [u8], @@ -391,6 +402,80 @@ impl<'a> Strings<'a> { } } +#[derive(Clone, Copy, Debug)] +struct LayerHeader { + string_count: usize, + string_offsets_offset: usize, + string_values_offset: usize, + id_index: u32, + source_id_index: u32, + width: f64, + height: Option, + contour_count: usize, + point_count: usize, + component_count: usize, + anchor_count: usize, + guideline_count: usize, + contours_offset: usize, + components_offset: usize, + anchors_offset: usize, + guidelines_offset: usize, + lib_offset: usize, + lib_end: usize, +} + +impl LayerHeader { + fn from_view(view: GlyphLayerView<'_>) -> Self { + Self { + string_count: view.strings.count, + string_offsets_offset: view.strings.offsets_offset, + string_values_offset: view.strings.values_offset, + id_index: view.id_index, + source_id_index: view.source_id_index, + width: view.width, + height: view.height, + contour_count: view.contour_count, + point_count: view.point_count, + component_count: view.component_count, + anchor_count: view.anchor_count, + guideline_count: view.guideline_count, + contours_offset: view.contours_offset, + components_offset: view.components_offset, + anchors_offset: view.anchors_offset, + guidelines_offset: view.guidelines_offset, + lib_offset: view.lib_offset, + lib_end: view.lib_end, + } + } + + fn view<'a>(self, bytes: &'a [u8]) -> GlyphLayerView<'a> { + GlyphLayerView { + bytes, + strings: Strings { + bytes, + count: self.string_count, + offsets_offset: self.string_offsets_offset, + values_offset: self.string_values_offset, + }, + id_index: self.id_index, + source_id_index: self.source_id_index, + width: self.width, + height: self.height, + contour_count: self.contour_count, + point_count: self.point_count, + component_count: self.component_count, + anchor_count: self.anchor_count, + guideline_count: self.guideline_count, + contours_offset: self.contours_offset, + components_offset: self.components_offset, + anchors_offset: self.anchors_offset, + guidelines_offset: self.guidelines_offset, + lib_offset: self.lib_offset, + lib_end: self.lib_end, + } + } +} + /// Validated allocation-free access to an authored layer payload. #[derive(Clone, Copy, Debug)] pub struct GlyphLayerView<'a> { @@ -458,7 +543,6 @@ impl<'a> GlyphLayerView<'a> { strings: self.strings, offset: self.components_offset, remaining: self.component_count, - index: 0, } } @@ -680,7 +764,6 @@ pub struct LayerComponentIter<'a> { strings: Strings<'a>, offset: usize, remaining: usize, - index: usize, } impl<'a> Iterator for LayerComponentIter<'a> { type Item = LayerComponentView<'a>; @@ -697,7 +780,6 @@ impl<'a> Iterator for LayerComponentIter<'a> { } self.offset += COMPONENT_LEN; self.remaining -= 1; - self.index += 1; Some(LayerComponentView { id, base_glyph_id, @@ -847,8 +929,83 @@ fn optional_string<'a>(strings: Strings<'a>, index: u32) -> Option<&'a str> { (index != NONE_STRING).then(|| strings.get(index)) } +fn validate_layer_input(layer: &GlyphLayer) -> Result<(), LayerCodecError> { + validate_finite(layer.width, "width", 0)?; + if let Some(height) = layer.height { + validate_finite(height, "height", 0)?; + } + + let mut contour_ids = HashSet::new(); + let mut point_ids = HashSet::new(); + let mut point_index = 0; + for (contour_index, contour) in layer.contours.iter().enumerate() { + if !contour_ids.insert(contour.id.as_str()) { + return Err(LayerCodecError::DuplicateIdentity { + kind: "contour", + index: contour_index, + }); + } + for point in &contour.points { + if !point_ids.insert(point.id.as_str()) { + return Err(LayerCodecError::DuplicateIdentity { + kind: "point", + index: point_index, + }); + } + validate_finite(point.x, "point coordinate", point_index * 2)?; + validate_finite(point.y, "point coordinate", point_index * 2 + 1)?; + point_index += 1; + } + } + + let mut component_ids = HashSet::new(); + for (index, component) in layer.components.iter().enumerate() { + if !component_ids.insert(component.id.as_str()) { + return Err(LayerCodecError::DuplicateIdentity { + kind: "component", + index, + }); + } + for (value_index, value) in component.transform.values().into_iter().enumerate() { + validate_finite(value, "component transform", index * 9 + value_index)?; + } + } + + let mut anchor_ids = HashSet::new(); + for (index, anchor) in layer.anchors.iter().enumerate() { + if !anchor_ids.insert(anchor.id.as_str()) { + return Err(LayerCodecError::DuplicateIdentity { + kind: "anchor", + index, + }); + } + validate_finite(anchor.x, "anchor coordinate", index * 2)?; + validate_finite(anchor.y, "anchor coordinate", index * 2 + 1)?; + } + + let mut guideline_ids = HashSet::new(); + for (index, guideline) in layer.guidelines.iter().enumerate() { + if !guideline_ids.insert(guideline.id.as_str()) { + return Err(LayerCodecError::DuplicateIdentity { + kind: "guideline", + index, + }); + } + for (value_index, value) in [guideline.x, guideline.y, guideline.angle] + .into_iter() + .enumerate() + { + if let Some(value) = value { + validate_finite(value, "guideline number", index * 3 + value_index)?; + } + } + } + Ok(()) +} + /// Canonically encodes a complete authored layer. pub fn pack_layer(layer: &GlyphLayer) -> Result { + validate_layer_input(layer)?; check_limit( "contour count", layer.contours.len(), @@ -917,6 +1074,7 @@ pub fn pack_layer(layer: &GlyphLayer) -> Result Result Result Result Result Result<(), LayerCodecError> { check_depth(depth)?; - for (key, value) in sorted_map(values) { + // BTreeMap's String order is the same lexicographic order as UTF-8 bytes. + for (key, value) in values { strings.intern(key)?; gather_value_strings(value, strings, depth + 1, count)?; } @@ -1521,9 +1711,9 @@ fn gather_value_strings( } } LayerLibValue::Dict(values) => gather_map_strings(values, strings, depth + 1, count)?, + LayerLibValue::Float(value) => validate_finite(*value, "lib float", *count - 1)?, LayerLibValue::Integer(_) | LayerLibValue::UnsignedInteger(_) - | LayerLibValue::Float(_) | LayerLibValue::Boolean(_) | LayerLibValue::Data(_) | LayerLibValue::Uid(_) => {} @@ -1540,7 +1730,7 @@ fn encode_map( ) -> Result<(), LayerCodecError> { check_depth(depth)?; push_u32(bytes, values.len())?; - for (key, value) in sorted_map(values) { + for (key, value) in values { push_u32(bytes, strings.index(key))?; encode_lib_value(bytes, value, strings, depth + 1, count)?; } @@ -1596,12 +1786,6 @@ fn encode_lib_value( Ok(()) } -fn sorted_map(values: &BTreeMap) -> Vec<(&String, &LayerLibValue)> { - let mut values = values.iter().collect::>(); - values.sort_unstable_by(|left, right| left.0.as_bytes().cmp(right.0.as_bytes())); - values -} - fn validate_strings(strings: Strings<'_>, expected_bytes: usize) -> Result<(), LayerCodecError> { if read_u32(strings.bytes, strings.offsets_offset) != 0 { return Err(LayerCodecError::InvalidStringOffsets { index: 0 }); diff --git a/crates/shift-glyph-codec/src/lib.rs b/crates/shift-glyph-codec/src/lib.rs index e7792aab..a2f3b232 100644 --- a/crates/shift-glyph-codec/src/lib.rs +++ b/crates/shift-glyph-codec/src/lib.rs @@ -8,5 +8,17 @@ mod frame; mod layer; mod outline; -pub use layer::*; -pub use outline::*; +pub use layer::{ + decode_layer, pack_layer, GlyphLayer, GlyphLayerView, LayerAnchor, LayerAnchorIter, + LayerAnchorView, LayerCodecError, LayerComponent, LayerComponentIter, LayerComponentView, + LayerContour, LayerContourIter, LayerContourView, LayerGuideline, LayerGuidelineIter, + LayerGuidelineView, LayerLibValue, LayerPoint, LayerPointIter, LayerPointType, LayerPointView, + LayerTransform, PackedGlyphLayer, MAX_LAYER_CONTOUR_COUNT, MAX_LAYER_ENTITY_COUNT, + MAX_LAYER_LIB_DEPTH, MAX_LAYER_LIB_VALUES, MAX_LAYER_PAYLOAD_BYTES, MAX_LAYER_POINT_COUNT, + MAX_LAYER_STRING_BYTES, MAX_LAYER_STRING_COUNT, +}; +pub use outline::{ + decode_outline, pack_outline, CodecError, CommandOrderError, CoordinateIter, OutlineCommand, + OutlineCommandIter, OutlineEncoder, OutlineView, PackedGlyphOutline, MAX_COMMAND_COUNT, + MAX_COORD_COUNT, MAX_PAYLOAD_BYTES, +}; diff --git a/crates/shift-glyph-codec/tests/fixtures.rs b/crates/shift-glyph-codec/tests/fixtures.rs index ddff7712..d5f521c8 100644 --- a/crates/shift-glyph-codec/tests/fixtures.rs +++ b/crates/shift-glyph-codec/tests/fixtures.rs @@ -17,6 +17,12 @@ struct BinaryVector { sha256: String, } +#[derive(Deserialize)] +struct OutlineManifest { + format: String, + vectors: Vec, +} + #[derive(Deserialize)] struct OutlineVector { name: String, @@ -51,8 +57,10 @@ fn layer_manifest_declares_every_canonical_fixture() { #[test] fn outline_manifest_declares_every_canonical_fixture() { let directory = fixture_directory("outline-v1"); - let vectors: Vec = + let manifest: OutlineManifest = serde_json::from_slice(&fs::read(directory.join("vectors.json")).unwrap()).unwrap(); + assert_eq!(manifest.format, "shift.glyph-outline.v1"); + let vectors = manifest.vectors; assert_unique_files(vectors.iter().map(|vector| vector.file.as_str())); assert_eq!( vectors diff --git a/fixtures/glyph-codec/layer-v1/guideline-flags.bin b/fixtures/glyph-codec/layer-v1/guideline-flags.bin new file mode 100644 index 0000000000000000000000000000000000000000..33487314de08f58408093e656f23c679de394aa9 GIT binary patch literal 584 zcmZ`$O;5r=5FJ4gKd$@&6As1@st5@l9C|Rst8gww*M`_&(pnNv;LU$s-Y{$iNO;Zb zdo%mGyWM>ozKkj-M8gWvH4*)Thp3}*x@{tA;3Zo@)If0qw!jYf48DS6Z~}gT3-Aw= zb<+6PWl>VhK3qDRyXZ5GlRW<}vUe9{E-BJDv%z*e&E5RR8iCn+vkzuFW*^OV&Gv%3 zHl6^#PF1i3AV(o*AQ^$3Lz53bINQm|-23RVuWK+mTUpBbid$CgZI$R84WIM(pqbtG sfaRWiPWGT^v3DC?4fq1a=VTB2=(E-#U&2W43MzY01g!d`x(YYC1|9KwC;$Ke literal 0 HcmV?d00001 diff --git a/fixtures/glyph-codec/layer-v1/lib-only.bin b/fixtures/glyph-codec/layer-v1/lib-only.bin new file mode 100644 index 0000000000000000000000000000000000000000..279a3192d35f628cd49b139fc91edbc6a91cc972 GIT binary patch literal 223 zcmWIca0_8#WMH_@z`(!;#HK)u4Y)xCfl84<12TsX$dLhJMIcrOVqG8xnU|ATnOYQ| zlbIBspO;fvoL^d$oEl%0nqHcdSd^MuP*RzfT3nKvQkIxgnp&KjT9TNOSdz#D)Cj_? jKnw!RKn!vq$N?-s3=#*4fdDd_fkD%O9ViCU%kUonWvUy$ literal 0 HcmV?d00001 diff --git a/fixtures/glyph-codec/layer-v1/vectors.json b/fixtures/glyph-codec/layer-v1/vectors.json index 97881074..d0d4328b 100644 --- a/fixtures/glyph-codec/layer-v1/vectors.json +++ b/fixtures/glyph-codec/layer-v1/vectors.json @@ -12,6 +12,18 @@ "description": "All point types, closure states, identity-bearing entities, optional fields, transforms, and lib value tags", "bytes": 945, "sha256": "1cc4172f22a4cd01682b8f328cc9df5e091c9ab143fff173ebdba0d32c7afc8f" + }, + { + "file": "lib-only.bin", + "description": "Nested layer lib values without authored geometry", + "bytes": 223, + "sha256": "db9930faa821b0e521d514898a198b2a26b9a03f5da37c8872b86f441774b190" + }, + { + "file": "guideline-flags.bin", + "description": "Every x, y, and angle presence-bit permutation", + "bytes": 584, + "sha256": "12855590657787565347a36eb0c03b24d3456917f33727f0231a738ba031ad90" } ] } diff --git a/fixtures/glyph-codec/outline-v1/vectors.json b/fixtures/glyph-codec/outline-v1/vectors.json index daad6c63..60b3c5ee 100644 --- a/fixtures/glyph-codec/outline-v1/vectors.json +++ b/fixtures/glyph-codec/outline-v1/vectors.json @@ -1,58 +1,100 @@ -[ - { - "name": "empty", - "file": "empty.bin", - "bytes": 16, - "sha256": "ae4497afa69fec3f60441c2af7359117fb419fa9b473af219735b21359db3b1e", - "commands": [] - }, - { - "name": "closed-line", - "file": "closed-line.bin", - "bytes": 36, - "sha256": "ff508aa8468d3ef1057cb47a8d9b507f0c81b0385821edde9a199c5cac3ab7b5", - "commands": [ - { "kind": "move", "coordinates": [0, 0] }, - { "kind": "line", "coordinates": [100, 200] }, - { "kind": "close", "coordinates": [] } - ] - }, - { - "name": "open", - "file": "open.bin", - "bytes": 36, - "sha256": "5115ef5e5b2249f877593011f9fa08359aef8cc7c90dee80a1568379091e059b", - "commands": [ - { "kind": "move", "coordinates": [-12.5, 0.25] }, - { "kind": "line", "coordinates": [123.75, -456.5] } - ] - }, - { - "name": "quadratic-cubic", - "file": "quadratic-cubic.bin", - "bytes": 68, - "sha256": "72fd95db7ed662d21cfa248d65ad8fdde1f3fd00efc524ee036fa449ac8797b6", - "commands": [ - { "kind": "move", "coordinates": [-1.5, 2.25] }, - { "kind": "quad", "coordinates": [0.5, -0.75, 1000.125, -2000.5] }, - { - "kind": "cubic", - "coordinates": [-3, 4.5, 5.25, -6.75, 100000000000000000000, -100000000000000000000] - }, - { "kind": "close", "coordinates": [] } - ] - }, - { - "name": "multiple-contours", - "file": "multiple-contours.bin", - "bytes": 56, - "sha256": "f2e1e7b8666d5921f68f9d2d01caa3e7ec1b52712bf2b18bc7915e028788e748", - "commands": [ - { "kind": "move", "coordinates": [0, 0] }, - { "kind": "line", "coordinates": [10, 0] }, - { "kind": "close", "coordinates": [] }, - { "kind": "move", "coordinates": [100.5, -100.25] }, - { "kind": "line", "coordinates": [200, 300] } - ] - } -] +{ + "format": "shift.glyph-outline.v1", + "vectors": [ + { + "name": "empty", + "file": "empty.bin", + "bytes": 16, + "sha256": "ae4497afa69fec3f60441c2af7359117fb419fa9b473af219735b21359db3b1e", + "commands": [] + }, + { + "name": "closed-line", + "file": "closed-line.bin", + "bytes": 36, + "sha256": "ff508aa8468d3ef1057cb47a8d9b507f0c81b0385821edde9a199c5cac3ab7b5", + "commands": [ + { + "kind": "move", + "coordinates": [0, 0] + }, + { + "kind": "line", + "coordinates": [100, 200] + }, + { + "kind": "close", + "coordinates": [] + } + ] + }, + { + "name": "open", + "file": "open.bin", + "bytes": 36, + "sha256": "5115ef5e5b2249f877593011f9fa08359aef8cc7c90dee80a1568379091e059b", + "commands": [ + { + "kind": "move", + "coordinates": [-12.5, 0.25] + }, + { + "kind": "line", + "coordinates": [123.75, -456.5] + } + ] + }, + { + "name": "quadratic-cubic", + "file": "quadratic-cubic.bin", + "bytes": 68, + "sha256": "72fd95db7ed662d21cfa248d65ad8fdde1f3fd00efc524ee036fa449ac8797b6", + "commands": [ + { + "kind": "move", + "coordinates": [-1.5, 2.25] + }, + { + "kind": "quad", + "coordinates": [0.5, -0.75, 1000.125, -2000.5] + }, + { + "kind": "cubic", + "coordinates": [-3, 4.5, 5.25, -6.75, 100000000000000000000, -100000000000000000000] + }, + { + "kind": "close", + "coordinates": [] + } + ] + }, + { + "name": "multiple-contours", + "file": "multiple-contours.bin", + "bytes": 56, + "sha256": "f2e1e7b8666d5921f68f9d2d01caa3e7ec1b52712bf2b18bc7915e028788e748", + "commands": [ + { + "kind": "move", + "coordinates": [0, 0] + }, + { + "kind": "line", + "coordinates": [10, 0] + }, + { + "kind": "close", + "coordinates": [] + }, + { + "kind": "move", + "coordinates": [100.5, -100.25] + }, + { + "kind": "line", + "coordinates": [200, 300] + } + ] + } + ] +} diff --git a/packages/glyph-codec/README.md b/packages/glyph-codec/README.md index b9636805..fd899dd4 100644 --- a/packages/glyph-codec/README.md +++ b/packages/glyph-codec/README.md @@ -19,7 +19,7 @@ interchangeable. Transport, persistence, `Path2D`, SVG debugging, SQLite, and Slug/GPU transformations belong to their respective consumers. Rust adaptation to the -editable object model lives in `shift-font::packed_layer`, keeping the codec +editable object model lives in shift-font's private `codec_adapter`, keeping the codec independent of font semantics. Implementation modules keep responsibilities narrow: `frame.ts` owns the common diff --git a/packages/glyph-codec/src/fixtures.test.ts b/packages/glyph-codec/src/fixtures.test.ts index c99d32a4..bbc72970 100644 --- a/packages/glyph-codec/src/fixtures.test.ts +++ b/packages/glyph-codec/src/fixtures.test.ts @@ -16,6 +16,11 @@ type LayerManifest = { readonly vectors: readonly BinaryVector[]; }; +type OutlineManifest = { + readonly format: string; + readonly vectors: readonly OutlineVector[]; +}; + type OutlineVector = BinaryVector & { readonly name: string; readonly commands: readonly ManifestCommand[]; @@ -44,7 +49,9 @@ describe("canonical glyph-codec fixtures", () => { it("declares every outline fixture's commands, length, digest, and canonical bytes", () => { const directory = fixtureUrl("outline-v1"); - const vectors = readJson(new URL("vectors.json", directory)); + const manifest = readJson(new URL("vectors.json", directory)); + expect(manifest.format).toBe("shift.glyph-outline.v1"); + const vectors = manifest.vectors; expect(new Set(vectors.map((vector) => vector.file)).size).toBe(vectors.length); expect(new Set(vectors.map((vector) => vector.name)).size).toBe(vectors.length); diff --git a/packages/glyph-codec/src/layer-lib.ts b/packages/glyph-codec/src/layer-lib.ts index 5e28bfe7..57ee6186 100644 --- a/packages/glyph-codec/src/layer-lib.ts +++ b/packages/glyph-codec/src/layer-lib.ts @@ -18,6 +18,19 @@ import { import { compareUtf8, DecodeState, StringPool } from "./layer-strings"; import type { LayerLibValue } from "./types"; +const LIB_TAGS: Record = { + string: 0, + integer: 1, + "unsigned-integer": 2, + float: 3, + boolean: 4, + array: 5, + dict: 6, + data: 7, + date: 8, + uid: 9, +}; + export function decodeMap( cursor: Cursor, state: DecodeState, @@ -33,7 +46,7 @@ export function decodeMap( const key = state.requiredString(cursor.u32()); if (previous !== null && compareUtf8(previous, key) >= 0) fail( - "noncanonical-map-order", + "non-canonical-map-order", `dictionary keys are not in canonical UTF-8 order at byte ${keyOffset}`, ); previous = key; @@ -199,19 +212,7 @@ function encodeLibValue( checkLayerDepth(depth); state.count = checkedAdd(state.count, 1); checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - const tags: Record = { - string: 0, - integer: 1, - "unsigned-integer": 2, - float: 3, - boolean: 4, - array: 5, - dict: 6, - data: 7, - date: 8, - uid: 9, - }; - writer.u8(tags[value.kind]); + writer.u8(LIB_TAGS[value.kind]); writer.u8(0); writer.u16(0); switch (value.kind) { diff --git a/packages/glyph-codec/src/layer-strings.ts b/packages/glyph-codec/src/layer-strings.ts index dbfd50e9..42c0e515 100644 --- a/packages/glyph-codec/src/layer-strings.ts +++ b/packages/glyph-codec/src/layer-strings.ts @@ -72,7 +72,7 @@ export class DecodeState { if (!this.#validateReferences) return; if (index > this.nextString) fail( - "noncanonical-string-reference", + "non-canonical-string-reference", `string reference ${index} is out of canonical first-use order; expected at most ${this.nextString}`, ); if (index === this.nextString) this.nextString += 1; @@ -87,7 +87,7 @@ export class DecodeState { return index === NONE_STRING ? null : this.requiredString(index); } - unique(kind: string, index: number, value: string): void { + uniqueIdentity(kind: string, index: number, value: string): void { let identities = this.#identities.get(kind); if (!identities) { identities = new Set(); diff --git a/packages/glyph-codec/src/layer.test.ts b/packages/glyph-codec/src/layer.test.ts index ae652170..3d8114af 100644 --- a/packages/glyph-codec/src/layer.test.ts +++ b/packages/glyph-codec/src/layer.test.ts @@ -226,7 +226,7 @@ describe("strict layer decoding", () => { const bytes = packLayer(emptyLayer()).toUint8Array(); bytes[71] = 0x80; - expect(errorCode(() => decodeLayer(bytes))).toBe("noncanonical-absent-number"); + expect(errorCode(() => decodeLayer(bytes))).toBe("non-canonical-absent-number"); }); it("rejects non-finite numbers, duplicate identity, and out-of-range integers", () => { diff --git a/packages/glyph-codec/src/layer.ts b/packages/glyph-codec/src/layer.ts index c70ed39e..854c0739 100644 --- a/packages/glyph-codec/src/layer.ts +++ b/packages/glyph-codec/src/layer.ts @@ -532,7 +532,7 @@ function validate(bytes: Uint8Array): Header { const contoursOffset = cursor.offset; let actualPoints = 0; for (let contourIndex = 0; contourIndex < contourCount; contourIndex += 1) { - state.unique("contour", contourIndex, state.requiredString(cursor.u32())); + state.uniqueIdentity("contour", contourIndex, state.requiredString(cursor.u32())); const count = cursor.u32(); actualPoints = checkedAdd(actualPoints, count); checkLimit("point count", actualPoints, MAX_LAYER_POINT_COUNT); @@ -544,7 +544,7 @@ function validate(bytes: Uint8Array): Header { ); for (let localIndex = 0; localIndex < count; localIndex += 1) { const pointIndex = state.pointIndex; - state.unique("point", pointIndex, state.requiredString(cursor.u32())); + state.uniqueIdentity("point", pointIndex, state.requiredString(cursor.u32())); pointTypeFromByte(cursor.u8(), pointIndex); const pointFlags = cursor.u8(); if ((pointFlags & ~1) !== 0) @@ -567,7 +567,7 @@ function validate(bytes: Uint8Array): Header { const componentsOffset = cursor.offset; for (let index = 0; index < componentCount; index += 1) { - state.unique("component", index, state.requiredString(cursor.u32())); + state.uniqueIdentity("component", index, state.requiredString(cursor.u32())); state.requiredString(cursor.u32()); state.requiredString(cursor.u32()); if (cursor.u32() !== 0) @@ -577,14 +577,14 @@ function validate(bytes: Uint8Array): Header { } const anchorsOffset = cursor.offset; for (let index = 0; index < anchorCount; index += 1) { - state.unique("anchor", index, state.requiredString(cursor.u32())); + state.uniqueIdentity("anchor", index, state.requiredString(cursor.u32())); state.optionalString(cursor.u32()); validateFinite(cursor.f64(), "anchor coordinate", index * 2); validateFinite(cursor.f64(), "anchor coordinate", index * 2 + 1); } const guidelinesOffset = cursor.offset; for (let index = 0; index < guidelineCount; index += 1) { - state.unique("guideline", index, state.requiredString(cursor.u32())); + state.uniqueIdentity("guideline", index, state.requiredString(cursor.u32())); state.optionalString(cursor.u32()); state.optionalString(cursor.u32()); const flags = cursor.u32(); @@ -703,7 +703,7 @@ function optionalString(strings: readonly string[], index: number): string | nul function validateAbsent(view: DataView, offset: number, field: string, index: number): void { if (view.getBigUint64(offset, true) !== 0n) fail( - "noncanonical-absent-number", + "non-canonical-absent-number", `absent ${field} value at index ${index} is not canonical positive zero`, ); } diff --git a/packages/glyph-codec/src/types.ts b/packages/glyph-codec/src/types.ts index a17a2d8f..6eeb4a9c 100644 --- a/packages/glyph-codec/src/types.ts +++ b/packages/glyph-codec/src/types.ts @@ -111,10 +111,10 @@ export type GlyphCodecErrorCode = | "invalid-string-offsets" | "duplicate-string" | "string-reference-out-of-range" - | "noncanonical-string-reference" + | "non-canonical-string-reference" | "unreferenced-strings" | "duplicate-identity" - | "noncanonical-map-order" + | "non-canonical-map-order" | "invalid-command-order" | "coordinate-count-mismatch" | "point-count-mismatch" @@ -124,7 +124,7 @@ export type GlyphCodecErrorCode = | "non-finite-input-coordinate" | "coordinate-out-of-f32-range" | "non-finite-number" - | "noncanonical-absent-number" + | "non-canonical-absent-number" | "integer-out-of-range" | "nesting-limit-exceeded" | "truncated" From ac8b5687053d4c3177cc07fd37c6071a8f2bfd4b Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 08:47:00 +0000 Subject: [PATCH 11/29] Finish lazy layer review cleanup --- Cargo.lock | 1 - crates/shift-backends/docs/DOCS.md | 14 +- crates/shift-backends/src/binary/reader.rs | 26 ++-- .../shift-backends/src/designspace/error.rs | 10 +- .../shift-backends/src/designspace/import.rs | 12 +- crates/shift-backends/src/ufo/import.rs | 36 ++--- crates/shift-backends/src/ufo/reader.rs | 2 +- crates/shift-bridge/src/bridge.rs | 24 +-- crates/shift-font/docs/DOCS.md | 4 +- .../src/{packed_layer.rs => codec_adapter.rs} | 8 +- crates/shift-font/src/intents.rs | 34 ----- crates/shift-font/src/ir/font.rs | 7 - crates/shift-font/src/lib.rs | 7 +- crates/shift-store/Cargo.toml | 1 - crates/shift-store/README.md | 9 +- crates/shift-store/src/font_state.rs | 2 +- crates/shift-store/src/lib.rs | 5 +- crates/shift-store/src/packed_layer.rs | 83 +---------- crates/shift-store/src/stream_writer.rs | 53 +------ crates/shift-workspace/docs/DOCS.md | 27 +++- .../examples/profile_streaming_import.rs | 58 ++++---- crates/shift-workspace/src/import_pipeline.rs | 60 ++++++++ crates/shift-workspace/src/ledger.rs | 68 +++++++-- crates/shift-workspace/src/lib.rs | 4 +- crates/shift-workspace/src/workspace.rs | 139 +++++++----------- .../shift-workspace/tests/workspace_test.rs | 78 +++++++++- 26 files changed, 388 insertions(+), 384 deletions(-) rename crates/shift-font/src/{packed_layer.rs => codec_adapter.rs} (97%) create mode 100644 crates/shift-workspace/src/import_pipeline.rs diff --git a/Cargo.lock b/Cargo.lock index f93c0e7d..b21a0797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2351,7 +2351,6 @@ dependencies = [ "rusqlite", "serde_json", "shift-font", - "shift-glyph-codec", "tempfile", "thiserror", ] diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index e2b0c364..48242f7a 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -20,7 +20,7 @@ Font format backends that convert between on-disk font files and the `Font` IR u **Architecture Invariant:** TrueType export compiles an owned snapshot of the Shift `Font` IR directly through fontir/fontc. It must not serialize a temporary UFO or fall back to another authoring format. WHY: `.shift` is the canonical authoring source, and an intermediate format would discard or reinterpret Shift concepts before compilation. -**Architecture Invariant:** TTF/OTF, UFO, and Designspace streaming imports parse glyphs in bounded Rayon batches and preserve input order when publishing each batch. SQLite remains outside this crate and is written by one workspace-owned sink. WHY: outline/GLIF parsing and canonical packing are parallel work, while concurrent SQLite authors would add contention and weaken transaction ownership. +**Architecture Invariant:** TTF/OTF, UFO, and Designspace streaming imports parse glyphs in bounded Rayon batches and preserve input order when publishing each batch. Eager readers drain those same canonical streams rather than maintaining a second parser. UFO and Designspace share `GlifGlyphStream`; only source discovery differs. SQLite remains outside this crate and is written by one workspace-owned sink. WHY: one conversion path prevents eager/streaming semantic drift, while concurrent SQLite authors would add contention and weaken transaction ownership. **Architecture Invariant:** Compiled-font streaming enumerates `maxp` glyph IDs, not only `cmap` mappings. Unencoded glyphs receive their `post`/CFF name or a synthesized `gidN` name, and all Unicode mappings for a glyph share one authored glyph identity. WHY: `cmap` is character lookup, not the complete glyph directory. @@ -31,20 +31,20 @@ Font format backends that convert between on-disk font files and the `Font` IR u ``` src/ lib.rs -- re-exports FontReader, FontWriter, FontBackend, FontImport, and sub-modules - import.rs -- glyph-free foreign header plus bounded glyph-batch cursor + import.rs -- glyph-free foreign header, bounded cursor, and shared GLIF stream traits.rs -- FontReader, FontWriter, FontBackend trait definitions ufo/ mod.rs -- UfoBackend convenience struct combining reader+writer; round-trip tests - import.rs -- metadata/directory-first GLIF batches without a complete norad or Shift font - reader.rs -- UfoReader: norad::Font -> shift_font::Font + import.rs -- UFO source discovery configured into the shared GLIF stream + reader.rs -- UfoReader eagerly drains the canonical UFO stream writer.rs -- UfoWriter: shift_font::Font -> atomically written norad::Font glyphs/ mod.rs -- GlyphsReader re-export; fixture-based integration tests reader.rs -- GlyphsReader: glyphs_reader::Font -> shift_font::Font (read-only) designspace/ - import.rs -- source directories plus glyph-major, sparse multi-source GLIF batches + import.rs -- Designspace source discovery configured into the shared GLIF stream binary/ - reader.rs -- eager and maxp-complete bounded TTF/OTF conversion + reader.rs -- maxp-complete TTF/OTF stream plus eager stream draining shift2fontir/ source.rs -- owned Shift FontView snapshot and fontir Source implementation axes.rs -- Shift axis/mapping conversion and source normalization @@ -72,7 +72,7 @@ src/ ## How it works -**Loading a font:** `FontLoader::read_font` retains the eager compatibility path. `FontLoader::stream_font` dispatches TTF/OTF, UFO, and Designspace sources to bounded importers. It first returns complete top-level metadata and a cheap glyph/source directory, then materializes at most the requested batch of `Glyph` values. Designspace source headers and layer directories are read in parallel and then published in stable source order. Rayon converts geometry records in parallel; indexed collection preserves glyph order. The workspace writes and releases each batch before requesting another. +**Loading a font:** `FontLoader::read_font` retains the eager API but the TTF/OTF, UFO, and Designspace readers implement it by draining their bounded streams. `FontLoader::stream_font` dispatches those sources to the same importers. It first returns complete top-level metadata and a cheap glyph/source directory, then materializes at most the requested batch of `Glyph` values. UFO and Designspace both feed shared GLIF work records into `GlifGlyphStream`; Designspace only adds stable multi-source discovery. Rayon converts geometry records in parallel; indexed collection preserves glyph order. The workspace writes and releases each batch before requesting another. **Point type mapping (read):** norad uses separate `Move`, `Line`, `Curve`, `OffCurve`, `QCurve` types. The IR collapses `Move`/`Line`/`Curve` into `OnCurve` and keeps `OffCurve` and `QCurve` distinct. On write, context (position in contour, open/closed, preceding point type) is used to reconstruct the correct norad variant. diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index b8fabe0b..9c30f7f0 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -10,9 +10,9 @@ use shift_font::{ MetricKind, NamedInstance, PointType, SourceId, }; use skrifa::{ - outline::{DrawSettings, OutlinePen}, + outline::{DrawSettings, OutlineGlyphCollection, OutlinePen}, prelude::{LocationRef, Size}, - raw::{types::GlyphId, ReadError, TableProvider}, + raw::{tables::hmtx::Hmtx, types::GlyphId, ReadError, TableProvider}, string::StringId, FontRef, MetadataProvider, }; @@ -226,9 +226,9 @@ pub(crate) fn stream_font_file(path: &str) -> FormatBackendResult<(Font, BinaryG FormatBackendError::Binary(format!("failed to read hmtx table: {error}")) })?; let header = font_header_from_skrifa(&font)?; - let source_id = header - .default_source_id() - .expect("binary font header should have a default source"); + let source_id = header.default_source_id().ok_or_else(|| { + FormatBackendError::Binary("binary font header is missing its default source".into()) + })?; let glyph_count = font .maxp() .map_err(|error| FormatBackendError::Binary(format!("failed to read maxp table: {error}")))? @@ -251,6 +251,8 @@ pub(crate) fn stream_font_file(path: &str) -> FormatBackendResult<(Font, BinaryG .unwrap_or_else(|| format!("gid{index}")); BinaryGlyphRecord { raw_id, + // Compiled fonts carry stable glyph order but no Shift IDs, so + // deterministic GID-derived identities make re-import repeatable. shift_id: ShiftGlyphId::from_raw(format!("binary{index}")), name: GlyphName::from(name), unicodes, @@ -297,10 +299,14 @@ impl GlyphStream for BinaryGlyphStream { let font = FontRef::new(self.bytes.as_ref()).map_err(|error| { FormatBackendError::Binary(format!("failed to reopen resident font: {error}")) })?; + let hmtx = font.hmtx().map_err(|error| { + FormatBackendError::Binary(format!("failed to read hmtx table: {error}")) + })?; + let outlines = font.outline_glyphs(); let source_id = &self.source_id; let glyphs = self.glyphs[self.next_glyph..end] .par_iter() - .map(|record| glyph_from_skrifa(&font, source_id, record)) + .map(|record| glyph_from_skrifa(&hmtx, &outlines, source_id, record)) .collect::>>()?; self.next_glyph = end; Ok(glyphs) @@ -308,13 +314,11 @@ impl GlyphStream for BinaryGlyphStream { } fn glyph_from_skrifa( - font: &FontRef<'_>, + hmtx: &Hmtx<'_>, + outlines: &OutlineGlyphCollection<'_>, source_id: &SourceId, record: &BinaryGlyphRecord, ) -> FormatBackendResult { - let hmtx = font.hmtx().map_err(|error| { - FormatBackendError::Binary(format!("failed to read hmtx table: {error}")) - })?; let advance_width = hmtx.advance(record.raw_id).ok_or_else(|| { FormatBackendError::Binary(format!("missing advance width for glyph {}", record.raw_id)) })?; @@ -324,7 +328,7 @@ fn glyph_from_skrifa( advance_width as f64, ); - if let Some(outline) = font.outline_glyphs().get(record.raw_id) { + if let Some(outline) = outlines.get(record.raw_id) { let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); let mut pen = ShiftPen::default(); outline.draw(settings, &mut pen).map_err(|error| { diff --git a/crates/shift-backends/src/designspace/error.rs b/crates/shift-backends/src/designspace/error.rs index 6501ac8f..a477946a 100644 --- a/crates/shift-backends/src/designspace/error.rs +++ b/crates/shift-backends/src/designspace/error.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use crate::errors::FormatBackendError; + pub type DesignspaceResult = Result; #[derive(Debug, thiserror::Error)] @@ -49,8 +51,12 @@ pub enum DesignspaceError { #[error("failed to save designspace '{path}': {details}")] SaveDesignspace { path: PathBuf, details: String }, - #[error("failed to load UFO '{path}': {details}")] - LoadUfo { path: PathBuf, details: String }, + #[error("failed to load UFO '{path}': {source}")] + LoadUfo { + path: PathBuf, + #[source] + source: Box, + }, #[error("failed to save UFO '{path}': {details}")] SaveUfo { path: PathBuf, details: String }, diff --git a/crates/shift-backends/src/designspace/import.rs b/crates/shift-backends/src/designspace/import.rs index 2ac18820..3df17961 100644 --- a/crates/shift-backends/src/designspace/import.rs +++ b/crates/shift-backends/src/designspace/import.rs @@ -67,7 +67,7 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG let default_path = designspace_dir.join(&default_descriptor.filename); let mut header = load_header(&default_path).map_err(|error| DesignspaceError::LoadUfo { path: default_path.clone(), - details: error.to_string(), + source: Box::new(error), })?; let default_metrics = header .default_source() @@ -97,7 +97,7 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG let ufo_header = load_header(&ufo_path).map_err(|error| DesignspaceError::LoadUfo { path: ufo_path.clone(), - details: error.to_string(), + source: Box::new(error), })?; let metrics = ufo_header .default_source() @@ -109,7 +109,7 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG read_glyph_paths(&ufo_path, descriptor.layer.as_deref()).map_err(|error| { DesignspaceError::LoadUfo { path: ufo_path, - details: error.to_string(), + source: Box::new(error), } })?; Ok((index, style_name, imported_metrics, glyphs)) @@ -164,7 +164,7 @@ fn stream_axisless_designspace( let default_path = designspace_dir.join(&sources[0].filename); let mut header = load_header(&default_path).map_err(|error| DesignspaceError::LoadUfo { path: default_path, - details: error.to_string(), + source: Box::new(error), })?; if let Some(family_name) = &sources[0].familyname { header.metadata_mut().family_name = Some(family_name.clone()); @@ -192,7 +192,7 @@ fn stream_axisless_designspace( let ufo_header = load_header(&ufo_path).map_err(|error| DesignspaceError::LoadUfo { path: ufo_path.clone(), - details: error.to_string(), + source: Box::new(error), })?; let metrics = ufo_header .default_source() @@ -208,7 +208,7 @@ fn stream_axisless_designspace( read_glyph_paths(&ufo_path, descriptor.layer.as_deref()).map_err(|error| { DesignspaceError::LoadUfo { path: ufo_path, - details: error.to_string(), + source: Box::new(error), } })?; Ok(( diff --git a/crates/shift-backends/src/ufo/import.rs b/crates/shift-backends/src/ufo/import.rs index 2da61246..46ed34a9 100644 --- a/crates/shift-backends/src/ufo/import.rs +++ b/crates/shift-backends/src/ufo/import.rs @@ -21,9 +21,9 @@ struct LayerDirectory { pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, GlifGlyphStream)> { let ufo_path = Path::new(path); let mut header = load_header(ufo_path)?; - let default_source_id = header - .default_source_id() - .expect("UFO header should contain its default source"); + let default_source_id = header.default_source_id().ok_or_else(|| { + FormatBackendError::Ufo("UFO header is missing its default source".into()) + })?; let layers = load_layer_directories(ufo_path, &mut header, default_source_id)?; let (glyph_ids, glyphs) = build_glyph_directory(layers); @@ -46,22 +46,16 @@ fn load_layer_directories( for (index, (name, relative_path)) in layer_paths.into_iter().enumerate() { let layer_path = ufo_path.join(&relative_path); let contents_path = layer_path.join("contents.plist"); - let glyphs: BTreeMap = - plist::from_file(&contents_path).map_err(|error| { - FormatBackendError::Ufo(format!( - "failed to read {}: {error}", - contents_path.display() - )) - })?; + let glyphs: BTreeMap = plist_file(&contents_path)?; let (color, lib) = read_layer_info(&layer_path)?; let source_id = if index == 0 { default_source_id.clone() } else { header.add_source(Source::layer(name)) }; - let source = header - .source_mut(source_id.clone()) - .expect("newly registered UFO source should exist"); + let source = header.source_mut(source_id.clone()).ok_or_else(|| { + FormatBackendError::Ufo("newly registered UFO source is missing".into()) + })?; source.set_color(color); if !lib.is_empty() { *source.lib_mut() = UfoReader::convert_lib(&lib); @@ -108,12 +102,7 @@ pub(crate) fn read_glyph_paths( })?; let layer_path = ufo_path.join(relative_path); let contents_path = layer_path.join("contents.plist"); - let glyphs: BTreeMap = plist::from_file(&contents_path).map_err(|error| { - FormatBackendError::Ufo(format!( - "failed to read {}: {error}", - contents_path.display() - )) - })?; + let glyphs: BTreeMap = plist_file(&contents_path)?; Ok(glyphs .into_iter() .map(|(name, path)| (name, layer_path.join(path))) @@ -123,9 +112,16 @@ pub(crate) fn read_glyph_paths( fn read_layer_paths(ufo_path: &Path) -> FormatBackendResult> { let path = ufo_path.join("layercontents.plist"); if !path.exists() { + // UFO 2 predates layercontents.plist and defines only glyphs/ as the + // default layer. Avoid loading a complete norad::Font just to discover + // this standard legacy layout. return Ok(vec![("public.default".into(), PathBuf::from("glyphs"))]); } - plist::from_file(&path).map_err(|error| { + plist_file(&path) +} + +fn plist_file(path: &Path) -> FormatBackendResult { + plist::from_file(path).map_err(|error| { FormatBackendError::Ufo(format!("failed to read {}: {error}", path.display())) }) } diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index faee1a72..b66515a4 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -240,7 +240,7 @@ impl UfoReader { .layers() .values() .next() - .expect("converted glyph should contain its layer") + .ok_or_else(|| FormatBackendError::Ufo("converted glyph is missing its layer".into()))? .as_ref() .clone(); for pending in pending_components { diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index 2ccbfe7b..eba7692d 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -32,7 +32,8 @@ use shift_wire::{ SourceMetricsInterpolationSnapshot, }; use shift_workspace::{ - FontWorkspace, NewWorkspace, PackageDraft, PackageIdentity, WorkspaceError, WorkspaceSource, + AcquireScope, FontWorkspace, NewWorkspace, PackageDraft, PackageIdentity, WorkspaceError, + WorkspaceSource, }; use std::{ collections::{HashSet, VecDeque}, @@ -731,9 +732,7 @@ impl Bridge { .iter() .map(|request| request.glyph_id.clone()) .collect::>(); - self.workspace_mut()?.acquire_glyphs(&glyph_ids, false)?; - - let font = self.font()?; + let font = self.acquire_and_font(&glyph_ids, AcquireScope::Glyphs)?; let mut snapshots = Vec::new(); for request in requests { let glyph_id = request.glyph_id; @@ -778,9 +777,7 @@ impl Bridge { .iter() .map(|glyph_id| parse::(glyph_id)) .collect::>>()?; - self.workspace_mut()?.acquire_glyphs(&glyph_ids, true)?; - - let font = self.font()?; + let font = self.acquire_and_font(&glyph_ids, AcquireScope::ComponentClosure)?; let mut projections = Vec::new(); let mut pending = glyph_ids.into_iter().collect::>(); let mut seen = HashSet::new(); @@ -822,9 +819,7 @@ impl Bridge { .map(|glyph_id| parse::(glyph_id)) .collect::>>()?; let location = map_location(location)?; - self.workspace_mut()?.acquire_glyphs(&glyph_ids, true)?; - - let font = self.font()?; + let font = self.acquire_and_font(&glyph_ids, AcquireScope::ComponentClosure)?; let mut projection = font.projection(&location); let previews = projection .glyphs(&glyph_ids)? @@ -1096,6 +1091,15 @@ impl Bridge { }) } + fn acquire_and_font( + &mut self, + glyph_ids: &[GlyphId], + scope: AcquireScope, + ) -> BridgeResult<&Font> { + self.workspace_mut()?.acquire_glyphs(glyph_ids, scope)?; + self.font() + } + fn font(&self) -> BridgeResult<&Font> { Ok(self.workspace()?.font()) } diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index f30b40f2..e4d1c10c 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -22,7 +22,7 @@ crates/shift-font/src/ intents.rs -- atomic authoring intents and semantic application changes.rs -- replace-grade semantic change records layer_edit.rs -- glyph-layer geometry mutations - packed_layer.rs -- lossless shift.glyph-layer.v1 domain adapter + codec_adapter.rs -- lossless shift.glyph-layer.v1 domain adapter variation.rs -- external-to-design mapping evaluation interpolation.rs -- source compatibility, reusable bases, source values projection.rs -- location-independent glyph payloads and resolved views @@ -92,7 +92,7 @@ Coordinates, advance width, smooth flags, anchor positions, and component transf `shift-wire` may translate native bases, source values, and projections into transport DTOs, but it must not rebuild source samples, define value ordering or topology compatibility, or evaluate variation models. -`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. The `packed_layer` module only adapts one domain layer to codec-owned bytes/views; it does not choose storage, caching, transactions, or loading policy. +`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. The private `codec_adapter` module only adapts one domain layer to codec-owned bytes/views; it does not choose storage, caching, transactions, or loading policy. `shift-font` should not own Electron, NAPI, or editor state. The TypeScript editor owns UI interaction, selection, hover, camera, tools, and command history. diff --git a/crates/shift-font/src/packed_layer.rs b/crates/shift-font/src/codec_adapter.rs similarity index 97% rename from crates/shift-font/src/packed_layer.rs rename to crates/shift-font/src/codec_adapter.rs index 1418ea0f..b0f110ba 100644 --- a/crates/shift-font/src/packed_layer.rs +++ b/crates/shift-font/src/codec_adapter.rs @@ -8,6 +8,8 @@ use std::str::FromStr; use shift_glyph_codec as codec; +pub use codec::{PackedGlyphLayer, MAX_LAYER_PAYLOAD_BYTES}; + use crate::{ Anchor, AnchorId, Component, ComponentId, Contour, ContourId, DecomposedTransform, GlyphId, GlyphLayer, GlyphName, Guideline, GuidelineId, LayerId, LibData, LibValue, Point, PointId, @@ -27,7 +29,7 @@ pub enum PackedLayerError { } /// Encodes one editable authored layer without involving SQLite or transport. -pub fn pack_glyph_layer(layer: &GlyphLayer) -> Result { +pub fn pack_glyph_layer(layer: &GlyphLayer) -> Result { codec::pack_layer(&to_codec_layer(layer)).map_err(Into::into) } @@ -37,9 +39,7 @@ pub fn unpack_glyph_layer(bytes: &[u8]) -> Result } /// Materializes one layer from an already-validated borrowed payload view. -pub fn glyph_layer_from_view( - view: codec::GlyphLayerView<'_>, -) -> Result { +fn glyph_layer_from_view(view: codec::GlyphLayerView<'_>) -> Result { let layer_id = parse_id::("layer id", view.id())?; let source_id = parse_id::("source id", view.source_id())?; let mut layer = GlyphLayer::with_width(layer_id, source_id, view.width()); diff --git a/crates/shift-font/src/intents.rs b/crates/shift-font/src/intents.rs index 102a9b8f..611547fb 100644 --- a/crates/shift-font/src/intents.rs +++ b/crates/shift-font/src/intents.rs @@ -1175,40 +1175,6 @@ impl Font { } } - /// Ids of glyphs whose components reference the glyphs owning the - /// given layers — the composites a renderer must re-render after an - /// edit. Stable ids (not names): references survive renames. Sorted, - /// deduplicated, excludes the touched glyphs themselves. - pub fn dependents_of_layers(&self, layer_ids: &[LayerId]) -> Vec { - let touched: Vec = self - .glyphs() - .filter(|glyph| { - glyph - .layers() - .keys() - .any(|layer_id| layer_ids.contains(layer_id)) - }) - .map(|glyph| glyph.id()) - .collect(); - - let mut dependents: Vec = self - .glyphs() - .filter(|glyph| !touched.contains(&glyph.id())) - .filter(|glyph| { - glyph.layers().values().any(|layer| { - layer - .components_iter() - .any(|component| touched.contains(&component.base_glyph_id())) - }) - }) - .map(|glyph| glyph.id()) - .collect(); - - dependents.sort(); - dependents.dedup(); - dependents - } - fn layer_mut_or_err(&mut self, layer_id: &LayerId) -> CoreResult<&mut GlyphLayer> { self.layer_mut(layer_id.clone()) .ok_or(CoreError::LayerNotFound(layer_id.clone())) diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index 06203aa6..a611def3 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -1106,13 +1106,6 @@ impl Font { Ok(()) } - /// Replaces an existing layer with the same identity. Used by bounded - /// storage acquisition to hydrate a directory placeholder without - /// changing glyph/layer ownership. - pub fn replace_glyph_layer(&mut self, layer: GlyphLayer) -> CoreResult<()> { - self.replace_glyph_layers(vec![layer]) - } - /// Atomically replaces existing layers while cloning and validating the /// font index only once for the complete batch. pub fn replace_glyph_layers(&mut self, layers: Vec) -> CoreResult<()> { diff --git a/crates/shift-font/src/lib.rs b/crates/shift-font/src/lib.rs index e61c06a9..e561b1c1 100644 --- a/crates/shift-font/src/lib.rs +++ b/crates/shift-font/src/lib.rs @@ -30,6 +30,7 @@ //! `shift-wire`. UI interaction belongs to the TypeScript editor. //! pub mod changes; +mod codec_adapter; pub mod composite; pub mod curve; pub mod error; @@ -37,12 +38,15 @@ pub mod intents; pub mod interpolation; pub mod ir; pub mod layer_edit; -pub mod packed_layer; pub mod projection; #[cfg(any(test, feature = "test-support"))] pub mod test_support; pub use changes::*; +pub use codec_adapter::{ + pack_glyph_layer, unpack_glyph_layer, PackedGlyphLayer, PackedLayerError, + MAX_LAYER_PAYLOAD_BYTES, +}; pub use error::{CoreError, CoreResult}; pub use intents::*; pub use interpolation::*; @@ -52,5 +56,4 @@ pub use ir::{ glyph_name, guideline, kerning, lib_data, metrics, named_instance, point, segment, source, variation, }; -pub use packed_layer::*; pub use projection::*; diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml index f53d663c..e8c40f8d 100644 --- a/crates/shift-store/Cargo.toml +++ b/crates/shift-store/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" [dependencies] shift-font = { workspace = true } -shift-glyph-codec = { workspace = true } rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } rayon = "1.10" serde_json = "1.0" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index db7ca86c..12fdd617 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -12,11 +12,11 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload; `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB of packed bytes. Both paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload; `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB of packed bytes. Explicit full-font materialization partitions the directory through that same bounded loader instead of issuing one query sequence per layer. All payload paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. ## Import boundary -`LayerStreamWriter` accepts one glyph/layer or a bounded glyph batch without requiring a complete in-memory `Font`. `write_glyph_batch` canonical-encodes layers with Rayon, then writes directory rows, BLOBs, and component indexes in stable order through one SQLite transaction. `finish` is the only commit point; dropping an unfinished writer rolls the complete stream back. +`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `write_glyph_batch` canonical-encodes layers with Rayon, then writes directory rows, BLOBs, and component indexes in stable order through one SQLite transaction. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. `finish` is the only stream commit point; dropping an unfinished writer rolls the complete stream back. `ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the destination is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL before workspace state is published. Normal edits never use import pragmas. Progress, cancellation, source fingerprinting, and final atomic destination installation remain workspace responsibilities. @@ -29,7 +29,7 @@ Shift has not shipped this working-store schema. Schema changes therefore update - configure SQLite for WAL-backed transactional work and a separately finalized disposable-import mode; - own the baseline schema and raw SQL; - preserve stable authored IDs and exact values; -- provide directory-first and bounded payload APIs; +- provide directory-first and bounded canonical payload APIs rather than row-level outline/component compatibility models; - keep canonical payloads and relational query indexes atomic; - support CJK-scale directories without BLOB scans. @@ -42,6 +42,7 @@ src/ font_state.rs # eager metadata/directory and explicit full materialization packed_layer.rs # bounded BLOB fetch/replace and reference-index checks stream_writer.rs # bounded Rayon pack plus single-transaction SQLite sink - change_set.rs # transactional workspace changes + write_mode.rs # shared insert/upsert policy for canonical write paths + change_set.rs # transactional workspace changes and one final touched-layer write workspace_state.rs ``` diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index cf11f1ac..a0e85e02 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -400,7 +400,7 @@ fn load_glyphs( if !batch.is_empty() && (batch.len() == crate::MAX_LAYER_READ_BATCH_COUNT || batch_bytes.saturating_add(byte_length) - > crate::MAX_LAYER_READ_BATCH_PAYLOAD_BYTES) + > crate::packed_layer::MAX_LAYER_READ_BATCH_PAYLOAD_BYTES) { load_layer_batch(conn, &mut batch, &mut loaded_layers)?; batch_bytes = 0; diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 76e7865c..96717e43 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -16,10 +16,7 @@ mod write_mode; pub use error::StoreError; pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; -pub use packed_layer::{ - GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT, - MAX_LAYER_READ_BATCH_PAYLOAD_BYTES, -}; +pub use packed_layer::{GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; pub use stream_writer::LayerStreamWriter; diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index 1a384da5..c8fab41d 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -9,8 +9,8 @@ use crate::{ pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; -pub const MAX_LAYER_READ_BATCH_PAYLOAD_BYTES: u64 = 256 * 1024 * 1024; -const MAX_LAYER_PAYLOAD_BYTES: i64 = shift_glyph_codec::MAX_LAYER_PAYLOAD_BYTES as i64; +pub(crate) const MAX_LAYER_READ_BATCH_PAYLOAD_BYTES: u64 = 256 * 1024 * 1024; +const MAX_LAYER_PAYLOAD_BYTES: i64 = font::MAX_LAYER_PAYLOAD_BYTES as i64; /// Relational facts needed to locate a layer without reading its payload. #[derive(Clone, Debug, PartialEq)] @@ -58,29 +58,6 @@ impl ShiftStore { Ok(()) } - /// Validates caller-provided canonical bytes before atomically replacing - /// directory facts, payload, reference rows, and revision state. - pub fn replace_glyph_layer_payload( - &mut self, - expected_layer_id: &font::LayerId, - payload: &[u8], - ) -> Result<(), StoreError> { - if payload.len() as i64 > MAX_LAYER_PAYLOAD_BYTES { - return Err(StoreError::LayerPayloadTooLarge { - bytes: payload.len() as u64, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } - let layer = font::unpack_glyph_layer(payload)?; - if layer.id() != *expected_layer_id { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: expected_layer_id.to_string(), - detail: format!("payload id is {}", layer.id()), - }); - } - self.replace_glyph_layer(&layer) - } - pub fn list_glyph_layer_directory(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( " @@ -473,7 +450,7 @@ pub(crate) fn store_packed_layer_in_tx( glyph_id: &font::GlyphId, name: Option<&font::GlyphName>, layer: &font::GlyphLayer, - packed: &shift_glyph_codec::PackedGlyphLayer, + packed: &font::PackedGlyphLayer, mode: WriteMode, ) -> Result<(), StoreError> { let bytes = packed.as_bytes(); @@ -745,11 +722,9 @@ mod tests { ) .unwrap(); - let mut writer = store.layer_stream_writer().unwrap(); - writer - .write_layer(&glyph_id, Some(&glyph_name), &layer) - .unwrap(); - writer.finish().unwrap(); + let tx = store.conn.transaction().unwrap(); + write_layer_in_tx(&tx, &glyph_id, Some(&glyph_name), &layer).unwrap(); + tx.commit().unwrap(); let stored: Vec = store .conn @@ -1018,52 +993,6 @@ mod tests { assert_eq!(reopened.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); } - #[test] - fn malformed_replacement_commits_nothing() { - let (mut store, font) = populated_store(); - let layer_id = font - .glyphs() - .next() - .unwrap() - .layers() - .keys() - .next() - .unwrap(); - let before = store.list_glyph_layer_directory().unwrap(); - let before_revision = store.workspace_state().unwrap().unwrap().revision; - - assert!( - store - .replace_glyph_layer_payload(layer_id, b"not a layer") - .is_err() - ); - - assert_eq!(store.list_glyph_layer_directory().unwrap(), before); - assert_eq!( - store.workspace_state().unwrap().unwrap().revision, - before_revision - ); - } - - #[test] - fn stream_writer_commits_one_standalone_layer() { - let (mut store, font) = populated_store(); - let glyph = font.glyphs().next().unwrap(); - let mut layer = glyph.layers().values().next().unwrap().as_ref().clone(); - layer.set_width(layer.width() + 17.0); - let glyph_id = glyph.id(); - let glyph_name = glyph.glyph_name().clone(); - drop(font); - - let mut writer = store.layer_stream_writer().unwrap(); - writer - .write_layer(&glyph_id, Some(&glyph_name), &layer) - .unwrap(); - writer.finish().unwrap(); - - assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); - } - #[test] fn abandoned_font_stream_rolls_back_header_and_glyphs() { let (mut store, font) = populated_store(); diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index 82ef5b1c..b4bf2bc6 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -6,52 +6,20 @@ use crate::{ ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, packed_layer::store_packed_layer_in_tx, - workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, }; /// Bounded transactional sink for format-specific importers. /// -/// A foreign reader can write one glyph or layer at a time without building a +/// A foreign reader can write one glyph batch at a time without building a /// complete [`font::Font`]. The transaction commits only when [`Self::finish`] /// is called; dropping the writer rolls the entire stream back. pub struct LayerStreamWriter<'store> { tx: Transaction<'store>, next_glyph_order: i64, - mode: StreamMode, -} - -#[derive(Clone, Copy)] -enum StreamMode { - Update, - Replacement, -} - -impl StreamMode { - fn write_mode(self) -> WriteMode { - match self { - Self::Update => WriteMode::Upsert, - Self::Replacement => WriteMode::Insert, - } - } } impl ShiftStore { - /// Starts a stream against the existing font directory. - pub fn layer_stream_writer(&mut self) -> Result, StoreError> { - let tx = self.conn.transaction()?; - let next_glyph_order = tx.query_row( - "SELECT COALESCE(MAX(order_index) + 1, 0) FROM glyphs", - [], - |row| row.get(0), - )?; - Ok(LayerStreamWriter { - tx, - next_glyph_order, - mode: StreamMode::Update, - }) - } - /// Starts an atomic replacement using a glyph-free font header. pub fn font_stream_writer( &mut self, @@ -62,7 +30,6 @@ impl ShiftStore { Ok(LayerStreamWriter { tx, next_glyph_order: 0, - mode: StreamMode::Replacement, }) } } @@ -86,9 +53,8 @@ impl LayerStreamWriter<'_> { }) .collect::, font::PackedLayerError>>()?; - let write_mode = self.mode.write_mode(); for (glyph, packed_layers) in glyphs.iter().zip(packed_layers) { - write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, write_mode)?; + write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, WriteMode::Insert)?; for (layer_id, packed) in packed_layers { let layer = glyph @@ -104,7 +70,7 @@ impl LayerStreamWriter<'_> { Some(glyph.glyph_name()), layer, &packed, - write_mode, + WriteMode::Insert, )?; } self.next_glyph_order += 1; @@ -112,21 +78,8 @@ impl LayerStreamWriter<'_> { Ok(()) } - /// Writes or replaces one independently-addressed layer. - pub fn write_layer( - &mut self, - glyph_id: &font::GlyphId, - glyph_name: Option<&font::GlyphName>, - layer: &font::GlyphLayer, - ) -> Result<(), StoreError> { - crate::packed_layer::write_layer_in_tx(&self.tx, glyph_id, glyph_name, layer) - } - /// Atomically publishes every glyph and layer written by this stream. pub fn finish(self) -> Result<(), StoreError> { - if matches!(self.mode, StreamMode::Update) { - mark_workspace_dirty_in_tx(&self.tx)?; - } self.tx.commit()?; Ok(()) } diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index ef33de1d..61ca74ce 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -7,13 +7,14 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** `FontWorkspace` composes a directory-complete, payload-lazy `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. - **Architecture Invariant:** Resuming SQLite loads metadata and glyph/layer directory facts only. `acquire_glyphs` performs explicit bounded payload I/O; synchronous `font()` reads never initiate I/O. - **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `LayerStreamWriter`; they never construct a complete geometry-resident `Font`. Format readers and canonical packing use Rayon, while SQLite has one transaction owner. -- **Architecture Invariant:** A loaded layer is only a cache of already-committed authored state. `evict_glyphs` replaces it with a directory placeholder and cannot lose an edit. +- **Architecture Invariant:** `LayerResidency` is the sole owner of loaded-layer membership and placeholder replacement. A loaded layer is only a cache of already-committed authored state; apply, undo, and redo reacquire their complete layer read sets before mutation, and `evict_glyphs` replaces only committed layers with directory placeholders. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. - **Architecture Invariant:** The workspace is the domain object future bridge or utility-process transports should wrap. - **Architecture Invariant:** Ledger replay restores complete named-instance collections after axis topology so undo/redo never observes an instance against the wrong external-axis shape. - **Architecture Invariant:** Metadata ledger entries store complete pre/post snapshots and replay them independently of font metrics. - **Architecture Invariant:** Metric-definition ledger state replays before complete source snapshots so source metric IDs are always valid during undo and redo. +- **Architecture Invariant:** Undo and redo retain at most 100 entries per stack. Extending either stack drops that stack's oldest entry; a fresh apply clears redo. ## Codemap @@ -22,7 +23,10 @@ crates/shift-workspace/src/ lib.rs -- public API barrel new_workspace.rs -- creation options for a fresh workspace source_identity.rs -- package identity snapshots and source-save validation - workspace.rs -- `FontWorkspace` and workspace errors + import_pipeline.rs -- shared two-batch parser/single-writer pipeline and progress observer + layer_residency.rs -- complete read sets, bounded acquisition, and safe eviction + ledger.rs -- bounded snapshot-pair undo/redo entries + workspace.rs -- `FontWorkspace` orchestration and workspace errors crates/shift-workspace/examples/ profile_streaming_import.rs -- shared release-mode foreign/native import profiler ``` @@ -48,11 +52,11 @@ crates/shift-workspace/examples/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. -`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, include_references)` fetches requested layers and can expand component closure from the relational reference index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of packed bytes, then decodes each batch into a COW candidate font. Each batch scans SQLite and copies the complete identity index only once; a malformed request does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of packed bytes, accumulates the decoded results, and installs them with one COW font-index rebuild. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. ## Profiling -`profile_streaming_import` reports foreign-directory, parse, pack/write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: +`profile_streaming_import` uses the same public `stream_into` two-batch pipeline as the workspace and reports foreign-directory, parse, pack/write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: ```bash cargo build --release -p shift-workspace --example profile_streaming_import @@ -60,7 +64,20 @@ cargo build --release -p shift-workspace --example profile_streaming_import /path/to/font-or-project /tmp/import.sqlite ``` -Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. The SQLite writer remains single-threaded. +Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse/write time, wall time, and throughput. The SQLite writer remains single-threaded. + +A repeatable ignored corpus gate exercises streaming import, directory-only +resume, and bounded acquisition without checking large fonts into Git: + +```bash +SHIFT_STRESS_FONT=/path/to/SourceHanSans-VF.ttf \ +SHIFT_STRESS_MIN_GLYPHS=65000 SHIFT_STRESS_MIN_LAYERS=65000 \ +cargo test -p shift-workspace configured_large_corpus_streams_resumes_and_acquires \ + -- --ignored --nocapture +``` + +Set both expectations for the selected, checksum-pinned local corpus. Timing and +RSS remain profiler observations rather than CI assertions. ## Verification diff --git a/crates/shift-workspace/examples/profile_streaming_import.rs b/crates/shift-workspace/examples/profile_streaming_import.rs index 95247c32..15173356 100644 --- a/crates/shift-workspace/examples/profile_streaming_import.rs +++ b/crates/shift-workspace/examples/profile_streaming_import.rs @@ -6,7 +6,7 @@ use std::{ use shift_backends::{ImportBatchLimit, font_loader::FontLoader}; use shift_store::{ShiftStore, WorkspaceState}; -use shift_workspace::FontWorkspace; +use shift_workspace::{AcquireScope, FontWorkspace, stream_into}; fn ms(duration: Duration) -> f64 { duration.as_secs_f64() * 1_000.0 @@ -24,6 +24,11 @@ fn main() -> Result<(), Box> { .filter(|value| *value > 0) .unwrap_or(1_024); let batch_limit = ImportBatchLimit::new(batch_glyphs, batch_layers); + let progress_batches = env::var("SHIFT_IMPORT_PROGRESS_BATCHES") + .ok() + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) + .unwrap_or(32); let source = PathBuf::from(env::args_os().nth(1).ok_or("missing source path")?); let store_path = PathBuf::from(env::args_os().nth(2).ok_or("missing store path")?); @@ -32,7 +37,7 @@ fn main() -> Result<(), Box> { let total_started = Instant::now(); let started = Instant::now(); - let mut import = FontLoader::new().stream_font(source_str)?; + let import = FontLoader::new().stream_font(source_str)?; println!("directory_ms={:.3}", ms(started.elapsed())); let started = Instant::now(); let directory = import.directory(); @@ -42,6 +47,7 @@ fn main() -> Result<(), Box> { drop(directory); println!("batch_glyph_limit={batch_glyphs}"); println!("batch_layer_limit={batch_layers}"); + println!("progress_batch_interval={progress_batches}"); let started = Instant::now(); let mut store = ShiftStore::open_for_import(&store_path)?; @@ -49,47 +55,35 @@ fn main() -> Result<(), Box> { println!("store_setup_ms={:.3}", ms(started.elapsed())); let pipeline_started = Instant::now(); - let (sender, receiver) = std::sync::mpsc::sync_channel(2); let mut parse = Duration::ZERO; let mut pack_and_write = Duration::ZERO; let mut glyph_count = 0; + let mut layer_count = 0; let mut batches = 0; - std::thread::scope(|scope| -> Result<(), Box> { - scope.spawn(move || { - loop { - let started = Instant::now(); - match import.next_batch(batch_limit) { - Ok(glyphs) if glyphs.is_empty() => break, - Ok(glyphs) => { - if sender.send(Ok((glyphs, started.elapsed()))).is_err() { - break; - } - } - Err(error) => { - let _ = sender.send(Err(error)); - break; - } - } - } - }); - - for result in receiver { - let (glyphs, parse_elapsed) = result?; - parse += parse_elapsed; - glyph_count += glyphs.len(); - batches += 1; + stream_into(import, &mut writer, batch_limit, |batch| { + parse += batch.parse_elapsed; + pack_and_write += batch.write_elapsed; + glyph_count += batch.glyph_count; + layer_count += batch.layer_count; + batches += 1; - let started = Instant::now(); - writer.write_glyph_batch(&glyphs)?; - pack_and_write += started.elapsed(); + if batches % progress_batches == 0 { + let wall = pipeline_started.elapsed(); + println!( + "progress batches={batches} glyphs={glyph_count} layers={layer_count} parse_ms={:.3} pack_and_write_ms={:.3} wall_ms={:.3} glyphs_per_sec={:.1}", + ms(parse), + ms(pack_and_write), + ms(wall), + glyph_count as f64 / wall.as_secs_f64() + ); } - Ok(()) })?; println!("parse_ms={:.3}", ms(parse)); println!("pack_and_write_ms={:.3}", ms(pack_and_write)); println!("pipeline_wall_ms={:.3}", ms(pipeline_started.elapsed())); println!("batch_count={batches}"); println!("glyph_count={glyph_count}"); + println!("streamed_layer_count={layer_count}"); let started = Instant::now(); writer.finish()?; @@ -138,7 +132,7 @@ fn main() -> Result<(), Box> { .cloned() .collect::>(); let started = Instant::now(); - workspace.acquire_glyphs(std::slice::from_ref(&glyph_id), false)?; + workspace.acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs)?; println!("one_glyph_acquire_ms={:.3}", ms(started.elapsed())); println!("one_glyph_loaded_layers={}", workspace.loaded_layer_count()); diff --git a/crates/shift-workspace/src/import_pipeline.rs b/crates/shift-workspace/src/import_pipeline.rs new file mode 100644 index 00000000..f2d90eee --- /dev/null +++ b/crates/shift-workspace/src/import_pipeline.rs @@ -0,0 +1,60 @@ +use std::time::{Duration, Instant}; + +use shift_backends::{FontImport, ImportBatchLimit}; +use shift_store::LayerStreamWriter; + +use crate::WorkspaceError; + +#[derive(Clone, Copy, Debug)] +pub struct ImportBatchProgress { + pub glyph_count: usize, + pub layer_count: usize, + pub parse_elapsed: Duration, + pub write_elapsed: Duration, +} + +/// Runs the bounded parser/SQLite pipeline with one writer and a two-batch +/// channel. The observer runs after each batch is durably staged in the open +/// transaction; it must remain lightweight so it does not throttle the writer. +pub fn stream_into( + mut import: FontImport, + writer: &mut LayerStreamWriter<'_>, + batch_limit: ImportBatchLimit, + mut observe: impl FnMut(ImportBatchProgress), +) -> Result<(), WorkspaceError> { + let (sender, receiver) = std::sync::mpsc::sync_channel(2); + std::thread::scope(|scope| -> Result<(), WorkspaceError> { + scope.spawn(move || { + loop { + let started = Instant::now(); + match import.next_batch(batch_limit) { + Ok(glyphs) if glyphs.is_empty() => break, + Ok(glyphs) => { + if sender.send(Ok((glyphs, started.elapsed()))).is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error)); + break; + } + } + } + }); + + for result in receiver { + let (glyphs, parse_elapsed) = result?; + let glyph_count = glyphs.len(); + let layer_count = glyphs.iter().map(|glyph| glyph.layers().len()).sum(); + let started = Instant::now(); + writer.write_glyph_batch(&glyphs)?; + observe(ImportBatchProgress { + glyph_count, + layer_count, + parse_elapsed, + write_elapsed: started.elapsed(), + }); + } + Ok(()) + }) +} diff --git a/crates/shift-workspace/src/ledger.rs b/crates/shift-workspace/src/ledger.rs index 1f96ba49..219b7922 100644 --- a/crates/shift-workspace/src/ledger.rs +++ b/crates/shift-workspace/src/ledger.rs @@ -12,9 +12,9 @@ use shift_font::{ MetricDefinition, NamedInstance, Source, SourceId, }; -/// Generous bound so a marathon session cannot grow memory unboundedly; -/// oldest entries fall off first. -const MAX_ENTRIES: usize = 100; +/// Maximum entries retained independently by each stack. The oldest entry on +/// the stack being extended falls off first; a fresh apply also clears redo. +const MAX_ENTRIES_PER_STACK: usize = 100; #[derive(Clone)] pub enum LedgerStep { @@ -139,11 +139,7 @@ impl Ledger { /// Records an applied entry. A fresh apply truncates the redo stack. pub fn push(&mut self, entry: LedgerEntry) { self.redo.clear(); - self.undo.push(entry); - - if self.undo.len() > MAX_ENTRIES { - self.undo.remove(0); - } + push_bounded(&mut self.undo, entry); } /// Pops the entry to undo; the caller replays its pre states and must @@ -155,12 +151,12 @@ impl Ledger { } pub fn record_undone(&mut self, entry: LedgerEntry) { - self.redo.push(entry); + push_bounded(&mut self.redo, entry); } /// Hands a popped undo entry back after a failed replay. pub fn restore_undo(&mut self, entry: LedgerEntry) { - self.undo.push(entry); + push_bounded(&mut self.undo, entry); } /// Pops the entry to redo; hand back via [`Ledger::record_redone`] after @@ -171,11 +167,59 @@ impl Ledger { } pub fn record_redone(&mut self, entry: LedgerEntry) { - self.undo.push(entry); + push_bounded(&mut self.undo, entry); } /// Hands a popped redo entry back after a failed replay. pub fn restore_redo(&mut self, entry: LedgerEntry) { - self.redo.push(entry); + push_bounded(&mut self.redo, entry); + } +} + +fn push_bounded(stack: &mut Vec, entry: LedgerEntry) { + stack.push(entry); + if stack.len() > MAX_ENTRIES_PER_STACK { + stack.remove(0); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_stack_drops_its_oldest_entry_independently() { + let mut ledger = Ledger::default(); + for index in 0..=MAX_ENTRIES_PER_STACK { + ledger.push(entry(index)); + } + assert_eq!(ledger.undo.len(), MAX_ENTRIES_PER_STACK); + assert_eq!(ledger.undo[0].label.as_deref(), Some("1")); + + let undo = std::mem::take(&mut ledger.undo); + for entry in undo { + ledger.record_undone(entry); + } + ledger.record_undone(entry(MAX_ENTRIES_PER_STACK + 1)); + assert_eq!(ledger.redo.len(), MAX_ENTRIES_PER_STACK); + assert_eq!(ledger.redo[0].label.as_deref(), Some("2")); + } + + #[test] + fn fresh_apply_clears_the_bounded_redo_stack() { + let mut ledger = Ledger::default(); + ledger.record_undone(entry(1)); + + ledger.push(entry(2)); + + assert!(ledger.redo.is_empty()); + assert_eq!(ledger.undo.len(), 1); + } + + fn entry(index: usize) -> LedgerEntry { + LedgerEntry { + label: Some(index.to_string()), + steps: Vec::new(), + } } } diff --git a/crates/shift-workspace/src/lib.rs b/crates/shift-workspace/src/lib.rs index 05ea4ba2..5704baae 100644 --- a/crates/shift-workspace/src/lib.rs +++ b/crates/shift-workspace/src/lib.rs @@ -1,10 +1,12 @@ +mod import_pipeline; mod layer_residency; mod ledger; mod new_workspace; mod source_identity; mod workspace; +pub use import_pipeline::{ImportBatchProgress, stream_into}; pub use ledger::{LayerPair, Ledger, LedgerEntry, LedgerStep}; pub use new_workspace::NewWorkspace; pub use source_identity::{PackageDraft, PackageIdentity}; -pub use workspace::{FontWorkspace, WorkspaceError, WorkspaceSource}; +pub use workspace::{AcquireScope, FontWorkspace, WorkspaceError, WorkspaceSource}; diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index df61230a..ab1fbb40 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -5,8 +5,7 @@ use std::{ }; use shift_backends::{ - FontExportRequest, FontExportResult, FontExporter, FontImport, ImportBatchLimit, - font_loader::FontLoader, + FontExportRequest, FontExportResult, FontExporter, ImportBatchLimit, font_loader::FontLoader, }; use shift_font::{ AppliedIntents, Axis, AxisId, FontChange, FontChangeSet, FontIntent, FontIntentSet, @@ -15,17 +14,17 @@ use shift_font::{ }; use shift_source::ShiftSourcePackage; use shift_store::{ - LayerStreamWriter, MAX_LAYER_READ_BATCH_COUNT, ShiftStore, SourceIdentitySnapshot, - WorkspaceSourceKind, WorkspaceState, + MAX_LAYER_READ_BATCH_COUNT, ShiftStore, SourceIdentitySnapshot, WorkspaceSourceKind, + WorkspaceState, }; -use crate::NewWorkspace; use crate::layer_residency::LayerResidency; use crate::ledger::{GlyphIdentity, LayerPair, Ledger, LedgerEntry, LedgerStep}; use crate::source_identity::{ PackageDraft, PackageIdentity, package_identity, source_identity_snapshot, validate_source_identity_for_save, }; +use crate::{NewWorkspace, stream_into}; #[derive(Debug, thiserror::Error)] pub enum WorkspaceError { @@ -76,6 +75,12 @@ pub enum WorkspaceSource { Imported { original_path: PathBuf }, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AcquireScope { + Glyphs, + ComponentClosure, +} + pub struct FontWorkspace { font: shift_font::Font, source: WorkspaceSource, @@ -85,6 +90,21 @@ pub struct FontWorkspace { } impl FontWorkspace { + fn from_store( + font: shift_font::Font, + source: WorkspaceSource, + store: ShiftStore, + residency: LayerResidency, + ) -> Self { + Self { + font, + source, + store, + ledger: Ledger::default(), + residency, + } + } + pub fn create_untitled( store_path: impl AsRef, new_workspace: NewWorkspace, @@ -96,13 +116,12 @@ impl FontWorkspace { store.replace_font_state(&font)?; store.set_workspace_state(WorkspaceState::untitled(None))?; - Ok(Self { + Ok(Self::from_store( font, - source: WorkspaceSource::Untitled, + WorkspaceSource::Untitled, store, - ledger: Ledger::default(), - residency: LayerResidency::default(), - }) + LayerResidency::default(), + )) } pub fn create_package( @@ -167,13 +186,7 @@ impl FontWorkspace { ); let source = source_from_workspace_state(&state)?; - Ok(Self { - font, - source, - store, - ledger: Ledger::default(), - residency, - }) + Ok(Self::from_store(font, source, store, residency)) } pub fn resume_for_source( @@ -715,15 +728,14 @@ impl FontWorkspace { let identity = source_identity_snapshot(source_package.path())?; store.set_workspace_state(WorkspaceState::package(identity, None))?; - Ok(Self { + Ok(Self::from_store( font, - source: WorkspaceSource::Package { + WorkspaceSource::Package { path: source_package.path().to_path_buf(), }, store, - ledger: Ledger::default(), - residency: LayerResidency::default(), - }) + LayerResidency::default(), + )) } fn import_font( @@ -739,7 +751,7 @@ impl FontWorkspace { Ok(import) => { let mut store = ShiftStore::open_for_import(store_path)?; let mut writer = store.font_stream_writer(import.header())?; - write_import_batches(import, &mut writer, ImportBatchLimit::default())?; + stream_into(import, &mut writer, ImportBatchLimit::default(), |_| {})?; writer.finish()?; store.finish_import()?; store.set_workspace_state(WorkspaceState::imported(import_path, None))?; @@ -749,15 +761,14 @@ impl FontWorkspace { .flat_map(|glyph| glyph.layers().keys().cloned()), ); - return Ok(Self { + return Ok(Self::from_store( font, - source: WorkspaceSource::Imported { + WorkspaceSource::Imported { original_path: import_path.to_path_buf(), }, store, - ledger: Ledger::default(), residency, - }); + )); } Err(shift_backends::BackendError::StreamingUnsupported { .. }) => {} Err(error) => return Err(error.into()), @@ -769,26 +780,24 @@ impl FontWorkspace { store.replace_font_state(&font)?; store.set_workspace_state(WorkspaceState::imported(import_path, None))?; - Ok(Self { + Ok(Self::from_store( font, - source: WorkspaceSource::Imported { + WorkspaceSource::Imported { original_path: import_path.to_path_buf(), }, store, - ledger: Ledger::default(), - residency: LayerResidency::default(), - }) + LayerResidency::default(), + )) } - /// Explicitly acquires requested glyph payloads. When `include_references` - /// is true, component dependencies are expanded from relational indexes - /// before any BLOB is read. + /// Explicitly acquires requested glyph payloads. Component closure expands + /// dependencies from relational indexes before any BLOB is read. pub fn acquire_glyphs( &mut self, glyph_ids: &[GlyphId], - include_references: bool, + scope: AcquireScope, ) -> Result<(), WorkspaceError> { - let glyph_ids = if include_references { + let glyph_ids = if scope == AcquireScope::ComponentClosure { self.store .referenced_glyph_closure(glyph_ids.iter().cloned())? } else { @@ -827,14 +836,14 @@ impl FontWorkspace { return Ok(()); } - // Decode and install bounded batches into a COW font clone. A malformed - // payload or cross-layer identity collision still leaves the live cache - // unchanged, while SQLite and the font index each process one batch. - let mut next_font = self.font.clone(); + // Decode through bounded SQLite reads, then clone and validate the font + // index once so any malformed batch leaves the live cache unchanged. + let mut layers = Vec::with_capacity(layer_ids.len()); for layer_ids in layer_ids.chunks(MAX_LAYER_READ_BATCH_COUNT) { - let layers = self.store.load_glyph_layers(layer_ids)?; - next_font.replace_glyph_layers(layers)?; + layers.extend(self.store.load_glyph_layers(layer_ids)?); } + let mut next_font = self.font.clone(); + next_font.replace_glyph_layers(layers)?; self.font = next_font; self.residency.mark_loaded(layer_ids); @@ -844,6 +853,9 @@ impl FontWorkspace { /// Drops clean in-memory payloads back to directory placeholders. Every /// authored edit is committed before the live font swap, so eviction can /// never discard unpersisted state. + /// + /// This is currently a test/profiler surface; the bridge does not yet own + /// a production eviction policy. pub fn evict_glyphs(&mut self, glyph_ids: &[GlyphId]) -> Result<(), WorkspaceError> { let mut placeholders = Vec::new(); let mut evicted = Vec::new(); @@ -865,20 +877,14 @@ impl FontWorkspace { } let mut next_font = self.font.clone(); - let mut batch = Vec::with_capacity(MAX_LAYER_READ_BATCH_COUNT); - for placeholder in placeholders { - batch.push(placeholder); - if batch.len() == MAX_LAYER_READ_BATCH_COUNT { - next_font.replace_glyph_layers(std::mem::take(&mut batch))?; - } - } - next_font.replace_glyph_layers(batch)?; + next_font.replace_glyph_layers(placeholders)?; self.font = next_font; self.residency.mark_unloaded(evicted); Ok(()) } + /// Residency instrumentation for tests and import profiling. pub fn loaded_layer_count(&self) -> usize { let directory_layer_count = self.font.glyphs().map(|glyph| glyph.layers().len()).sum(); self.residency.loaded_count(directory_layer_count) @@ -1336,37 +1342,6 @@ fn replay_glyph_layer( Ok(()) } -fn write_import_batches( - mut import: FontImport, - writer: &mut LayerStreamWriter<'_>, - batch_limit: ImportBatchLimit, -) -> Result<(), WorkspaceError> { - let (sender, receiver) = std::sync::mpsc::sync_channel(2); - std::thread::scope(|scope| -> Result<(), WorkspaceError> { - scope.spawn(move || { - loop { - match import.next_batch(batch_limit) { - Ok(glyphs) if glyphs.is_empty() => break, - Ok(glyphs) => { - if sender.send(Ok(glyphs)).is_err() { - break; - } - } - Err(error) => { - let _ = sender.send(Err(error)); - break; - } - } - } - }); - - for glyphs in receiver { - writer.write_glyph_batch(&glyphs?)?; - } - Ok(()) - }) -} - fn font_info_from_font(font: &shift_font::Font) -> shift_store::FontInfo { let metadata = font.metadata(); let metrics = font.metrics(); diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 3ca7e27c..75662bcc 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -6,7 +6,7 @@ use shift_font::{ PointSeed, PointType, SourceId, error::CoreError, }; use shift_source::ShiftSourcePackage; -use shift_workspace::{FontWorkspace, NewWorkspace, WorkspaceError, WorkspaceSource}; +use shift_workspace::{AcquireScope, FontWorkspace, NewWorkspace, WorkspaceError, WorkspaceSource}; fn create_glyph_intent(name: &str, unicodes: Vec) -> FontIntent { FontIntent::CreateGlyph { @@ -174,7 +174,9 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { .map(|glyph| glyph.id()) .collect::>(); assert_eq!(workspace.loaded_layer_count(), 0); - workspace.acquire_glyphs(&requested, false).unwrap(); + workspace + .acquire_glyphs(&requested, AcquireScope::Glyphs) + .unwrap(); let expected = requested .iter() .flat_map(|glyph_id| { @@ -199,7 +201,9 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { assert_eq!(resumed.font().glyph_count(), glyph_count); assert_eq!(resumed.loaded_layer_count(), 0); - resumed.acquire_glyphs(&requested, false).unwrap(); + resumed + .acquire_glyphs(&requested, AcquireScope::Glyphs) + .unwrap(); assert_eq!(resumed.loaded_layer_count(), expected.len()); assert!(resumed.loaded_layer_count() < total_layers); @@ -213,6 +217,64 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { } } +/// Manual corpus gate for large fonts that cannot live in the repository. +/// +/// Run with, for example: +/// `SHIFT_STRESS_FONT=/path/to/SourceHanSans-VF.ttf SHIFT_STRESS_MIN_GLYPHS=65000 SHIFT_STRESS_MIN_LAYERS=65000 cargo test -p shift-workspace configured_large_corpus_streams_resumes_and_acquires -- --ignored --nocapture`. +#[test] +#[ignore = "requires SHIFT_STRESS_FONT and explicit corpus expectations"] +fn configured_large_corpus_streams_resumes_and_acquires() { + let source_path = PathBuf::from( + std::env::var_os("SHIFT_STRESS_FONT").expect("SHIFT_STRESS_FONT must name a font source"), + ); + let min_glyphs = std::env::var("SHIFT_STRESS_MIN_GLYPHS") + .expect("SHIFT_STRESS_MIN_GLYPHS must document the selected corpus") + .parse::() + .expect("SHIFT_STRESS_MIN_GLYPHS must be an integer"); + let min_layers = std::env::var("SHIFT_STRESS_MIN_LAYERS") + .expect("SHIFT_STRESS_MIN_LAYERS must document the selected corpus") + .parse::() + .expect("SHIFT_STRESS_MIN_LAYERS must be an integer"); + let temp = tempfile::tempdir().unwrap(); + let store_path = temp.path().join("corpus.sqlite"); + let started = std::time::Instant::now(); + + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let glyph_count = workspace.font().glyph_count(); + let layer_count = workspace + .font() + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::(); + assert!(glyph_count >= min_glyphs, "got {glyph_count} glyphs"); + assert!(layer_count >= min_layers, "got {layer_count} layers"); + assert_eq!(workspace.loaded_layer_count(), 0); + + let glyph_ids = workspace + .font() + .glyphs() + .filter(|glyph| !glyph.layers().is_empty()) + .take(16) + .map(|glyph| glyph.id()) + .collect::>(); + workspace + .acquire_glyphs(&glyph_ids, AcquireScope::Glyphs) + .unwrap(); + assert!(workspace.loaded_layer_count() > 0); + assert!(workspace.loaded_layer_count() < layer_count); + drop(workspace); + + let resumed = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(resumed.font().glyph_count(), glyph_count); + assert_eq!(resumed.loaded_layer_count(), 0); + eprintln!( + "corpus={} glyphs={glyph_count} layers={layer_count} store_bytes={} elapsed_ms={:.3}", + source_path.display(), + fs::metadata(&store_path).unwrap().len(), + started.elapsed().as_secs_f64() * 1_000.0 + ); +} + #[test] fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { let temp = tempfile::tempdir().unwrap(); @@ -259,7 +321,7 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { .sum::(); assert_eq!(workspace.loaded_layer_count(), 0); workspace - .acquire_glyphs(std::slice::from_ref(&root), true) + .acquire_glyphs(std::slice::from_ref(&root), AcquireScope::ComponentClosure) .unwrap(); let expected_root_layers = workspace .font() @@ -279,7 +341,7 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { let mut resumed = FontWorkspace::resume(&store_path).unwrap(); assert_eq!(resumed.loaded_layer_count(), 0); resumed - .acquire_glyphs(std::slice::from_ref(&root), true) + .acquire_glyphs(std::slice::from_ref(&root), AcquireScope::ComponentClosure) .unwrap(); assert_eq!(resumed.loaded_layer_count(), closure_layer_count); @@ -400,7 +462,7 @@ fn resumed_layers_are_acquired_and_evictable_without_losing_authored_state() { assert!(workspace.font().layer(layer_id.clone()).unwrap().is_empty()); workspace - .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs) .unwrap(); assert_eq!(workspace.loaded_layer_count(), 1); let authored = workspace.font().layer(layer_id.clone()).unwrap().clone(); @@ -413,7 +475,7 @@ fn resumed_layers_are_acquired_and_evictable_without_losing_authored_state() { assert!(workspace.font().layer(layer_id.clone()).unwrap().is_empty()); workspace - .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs) .unwrap(); assert_eq!(workspace.font().layer(layer_id).unwrap(), &authored); } @@ -451,7 +513,7 @@ fn intent_on_directory_layer_acquires_before_persisting() { let mut resumed = FontWorkspace::resume(&store_path).unwrap(); resumed - .acquire_glyphs(std::slice::from_ref(&glyph_id), false) + .acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs) .unwrap(); let layer = resumed.font().layer(layer_id).unwrap(); assert_eq!(layer.width(), 777.0); From 50bdc77cd039671fd603872f6d5c89be7ca6a322 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 19:59:24 +0000 Subject: [PATCH 12/29] Pipeline canonical layer packing and writes --- crates/shift-store/src/lib.rs | 4 +- crates/shift-store/src/stream_writer.rs | 127 +++++++++++++----- crates/shift-store/src/types.rs | 26 ++++ crates/shift-workspace/docs/DOCS.md | 6 +- .../examples/profile_streaming_import.rs | 14 +- crates/shift-workspace/src/import_pipeline.rs | 87 +++++++++--- 6 files changed, 202 insertions(+), 62 deletions(-) diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 96717e43..b91b8920 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -19,8 +19,8 @@ pub use glyph::{GlyphRecord, NewGlyph}; pub use packed_layer::{GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; -pub use stream_writer::LayerStreamWriter; -pub use types::{AxisId, GlyphId, RevisionId, SourceId}; +pub use stream_writer::{LayerStreamWriter, pack_glyph_batch}; +pub use types::{AxisId, GlyphId, LayerBatchTiming, PackedGlyphBatch, RevisionId, SourceId}; pub use workspace_state::{ Evidence, FileIdentity, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState, }; diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index b4bf2bc6..f8969464 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -1,11 +1,14 @@ +use std::time::Instant; + use rayon::prelude::*; use rusqlite::Transaction; use shift_font as font; use crate::{ - ShiftStore, StoreError, + LayerBatchTiming, PackedGlyphBatch, ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, packed_layer::store_packed_layer_in_tx, + types::PackedGlyph, write_mode::WriteMode, }; @@ -34,47 +37,87 @@ impl ShiftStore { } } +/// Packs an owned bounded glyph batch in parallel without touching SQLite. +/// The returned batch retains glyph directory facts for the single writer. +pub fn pack_glyph_batch(glyphs: Vec) -> Result { + let started = Instant::now(); + let layers = pack_glyph_layers(&glyphs)?; + let pack_elapsed = started.elapsed(); + let glyphs = glyphs + .into_iter() + .zip(layers) + .map(|(glyph, layers)| PackedGlyph { glyph, layers }) + .collect(); + + Ok(PackedGlyphBatch { + glyphs, + pack_elapsed, + }) +} + impl LayerStreamWriter<'_> { /// Writes one complete glyph and all of its currently authored layers. pub fn write_glyph(&mut self, glyph: &font::Glyph) -> Result<(), StoreError> { - self.write_glyph_batch(std::slice::from_ref(glyph)) + self.write_glyph_batch(std::slice::from_ref(glyph))?; + Ok(()) } /// Packs a bounded glyph batch in parallel, then writes it in stable order. - pub fn write_glyph_batch(&mut self, glyphs: &[font::Glyph]) -> Result<(), StoreError> { - let packed_layers = glyphs - .par_iter() - .map(|glyph| { - glyph - .layers() - .values() - .map(|layer| font::pack_glyph_layer(layer).map(|packed| (layer.id(), packed))) - .collect::, _>>() - }) - .collect::, font::PackedLayerError>>()?; + pub fn write_glyph_batch( + &mut self, + glyphs: &[font::Glyph], + ) -> Result { + let started = Instant::now(); + let layers = pack_glyph_layers(glyphs)?; + let pack_elapsed = started.elapsed(); - for (glyph, packed_layers) in glyphs.iter().zip(packed_layers) { - write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, WriteMode::Insert)?; - for (layer_id, packed) in packed_layers { - let layer = - glyph - .layers() - .get(&layer_id) - .ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: layer_id.to_string(), - })?; - store_packed_layer_in_tx( - &self.tx, - &glyph.id(), - Some(glyph.glyph_name()), - layer, - &packed, - WriteMode::Insert, - )?; - } - self.next_glyph_order += 1; + let started = Instant::now(); + for (glyph, layers) in glyphs.iter().zip(&layers) { + self.write_packed_glyph(glyph, layers)?; } + + Ok(LayerBatchTiming { + pack_elapsed, + sqlite_elapsed: started.elapsed(), + }) + } + + /// Writes a pre-packed batch in stable order through the owned transaction. + pub fn write_packed_glyph_batch( + &mut self, + batch: PackedGlyphBatch, + ) -> Result { + let started = Instant::now(); + for glyph in &batch.glyphs { + self.write_packed_glyph(&glyph.glyph, &glyph.layers)?; + } + Ok(started.elapsed()) + } + + fn write_packed_glyph( + &mut self, + glyph: &font::Glyph, + layers: &[(font::LayerId, font::PackedGlyphLayer)], + ) -> Result<(), StoreError> { + write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, WriteMode::Insert)?; + for (layer_id, packed) in layers { + let layer = glyph + .layers() + .get(layer_id) + .ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer_id.to_string(), + })?; + store_packed_layer_in_tx( + &self.tx, + &glyph.id(), + Some(glyph.glyph_name()), + layer, + packed, + WriteMode::Insert, + )?; + } + self.next_glyph_order += 1; Ok(()) } @@ -84,3 +127,19 @@ impl LayerStreamWriter<'_> { Ok(()) } } + +fn pack_glyph_layers( + glyphs: &[font::Glyph], +) -> Result>, StoreError> { + glyphs + .par_iter() + .map(|glyph| { + glyph + .layers() + .values() + .map(|layer| font::pack_glyph_layer(layer).map(|packed| (layer.id(), packed))) + .collect::, _>>() + }) + .collect::, font::PackedLayerError>>() + .map_err(Into::into) +} diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index daf006ed..9e95619c 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -1,3 +1,29 @@ +use std::time::Duration; + +use shift_font::{Glyph, LayerId, PackedGlyphLayer}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct LayerBatchTiming { + pub pack_elapsed: Duration, + pub sqlite_elapsed: Duration, +} + +pub struct PackedGlyphBatch { + pub(crate) glyphs: Vec, + pub(crate) pack_elapsed: Duration, +} + +impl PackedGlyphBatch { + pub fn pack_elapsed(&self) -> Duration { + self.pack_elapsed + } +} + +pub(crate) struct PackedGlyph { + pub(crate) glyph: Glyph, + pub(crate) layers: Vec<(LayerId, PackedGlyphLayer)>, +} + #[derive(Clone, Debug, Eq, PartialEq, Hash)] pub struct AxisId(String); diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 61ca74ce..bf866446 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -23,7 +23,7 @@ crates/shift-workspace/src/ lib.rs -- public API barrel new_workspace.rs -- creation options for a fresh workspace source_identity.rs -- package identity snapshots and source-save validation - import_pipeline.rs -- shared two-batch parser/single-writer pipeline and progress observer + import_pipeline.rs -- bounded parser/parallel-packer/single-writer pipeline and progress observer layer_residency.rs -- complete read sets, bounded acquisition, and safe eviction ledger.rs -- bounded snapshot-pair undo/redo entries workspace.rs -- `FontWorkspace` orchestration and workspace errors @@ -56,7 +56,7 @@ crates/shift-workspace/examples/ ## Profiling -`profile_streaming_import` uses the same public `stream_into` two-batch pipeline as the workspace and reports foreign-directory, parse, pack/write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: +`profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, canonical pack, SQLite write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: ```bash cargo build --release -p shift-workspace --example profile_streaming_import @@ -64,7 +64,7 @@ cargo build --release -p shift-workspace --example profile_streaming_import /path/to/font-or-project /tmp/import.sqlite ``` -Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse/write time, wall time, and throughput. The SQLite writer remains single-threaded. +Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse, pack, SQLite, wall time, and throughput. Canonical packing uses Rayon; the SQLite writer remains single-threaded. A repeatable ignored corpus gate exercises streaming import, directory-only resume, and bounded acquisition without checking large fonts into Git: diff --git a/crates/shift-workspace/examples/profile_streaming_import.rs b/crates/shift-workspace/examples/profile_streaming_import.rs index 15173356..1a858423 100644 --- a/crates/shift-workspace/examples/profile_streaming_import.rs +++ b/crates/shift-workspace/examples/profile_streaming_import.rs @@ -56,13 +56,15 @@ fn main() -> Result<(), Box> { let pipeline_started = Instant::now(); let mut parse = Duration::ZERO; - let mut pack_and_write = Duration::ZERO; + let mut pack = Duration::ZERO; + let mut sqlite = Duration::ZERO; let mut glyph_count = 0; let mut layer_count = 0; let mut batches = 0; stream_into(import, &mut writer, batch_limit, |batch| { parse += batch.parse_elapsed; - pack_and_write += batch.write_elapsed; + pack += batch.pack_elapsed; + sqlite += batch.sqlite_elapsed; glyph_count += batch.glyph_count; layer_count += batch.layer_count; batches += 1; @@ -70,16 +72,18 @@ fn main() -> Result<(), Box> { if batches % progress_batches == 0 { let wall = pipeline_started.elapsed(); println!( - "progress batches={batches} glyphs={glyph_count} layers={layer_count} parse_ms={:.3} pack_and_write_ms={:.3} wall_ms={:.3} glyphs_per_sec={:.1}", + "progress batches={batches} glyphs={glyph_count} layers={layer_count} parse_ms={:.3} pack_ms={:.3} sqlite_ms={:.3} wall_ms={:.3} glyphs_per_sec={:.1}", ms(parse), - ms(pack_and_write), + ms(pack), + ms(sqlite), ms(wall), glyph_count as f64 / wall.as_secs_f64() ); } })?; println!("parse_ms={:.3}", ms(parse)); - println!("pack_and_write_ms={:.3}", ms(pack_and_write)); + println!("pack_ms={:.3}", ms(pack)); + println!("sqlite_ms={:.3}", ms(sqlite)); println!("pipeline_wall_ms={:.3}", ms(pipeline_started.elapsed())); println!("batch_count={batches}"); println!("glyph_count={glyph_count}"); diff --git a/crates/shift-workspace/src/import_pipeline.rs b/crates/shift-workspace/src/import_pipeline.rs index f2d90eee..a3d180d8 100644 --- a/crates/shift-workspace/src/import_pipeline.rs +++ b/crates/shift-workspace/src/import_pipeline.rs @@ -1,7 +1,8 @@ use std::time::{Duration, Instant}; use shift_backends::{FontImport, ImportBatchLimit}; -use shift_store::LayerStreamWriter; +use shift_font::Glyph; +use shift_store::{LayerStreamWriter, PackedGlyphBatch, pack_glyph_batch}; use crate::WorkspaceError; @@ -10,19 +11,36 @@ pub struct ImportBatchProgress { pub glyph_count: usize, pub layer_count: usize, pub parse_elapsed: Duration, - pub write_elapsed: Duration, + pub pack_elapsed: Duration, + pub sqlite_elapsed: Duration, } -/// Runs the bounded parser/SQLite pipeline with one writer and a two-batch -/// channel. The observer runs after each batch is durably staged in the open -/// transaction; it must remain lightweight so it does not throttle the writer. +struct ParsedImportBatch { + glyphs: Vec, + glyph_count: usize, + layer_count: usize, + parse_elapsed: Duration, +} + +struct PackedImportBatch { + glyphs: PackedGlyphBatch, + glyph_count: usize, + layer_count: usize, + parse_elapsed: Duration, +} + +/// Runs a bounded parser/packer/SQLite pipeline with one writer. Rendezvous +/// channels keep one batch active per stage without retaining queued batches. +/// The observer runs after each batch is staged in the open transaction; it +/// must remain lightweight so it does not throttle the writer. pub fn stream_into( mut import: FontImport, writer: &mut LayerStreamWriter<'_>, batch_limit: ImportBatchLimit, mut observe: impl FnMut(ImportBatchProgress), ) -> Result<(), WorkspaceError> { - let (sender, receiver) = std::sync::mpsc::sync_channel(2); + let (parsed_sender, parsed_receiver) = std::sync::mpsc::sync_channel(0); + let (packed_sender, packed_receiver) = std::sync::mpsc::sync_channel(0); std::thread::scope(|scope| -> Result<(), WorkspaceError> { scope.spawn(move || { loop { @@ -30,29 +48,62 @@ pub fn stream_into( match import.next_batch(batch_limit) { Ok(glyphs) if glyphs.is_empty() => break, Ok(glyphs) => { - if sender.send(Ok((glyphs, started.elapsed()))).is_err() { + let batch = ParsedImportBatch { + glyph_count: glyphs.len(), + layer_count: glyphs.iter().map(|glyph| glyph.layers().len()).sum(), + glyphs, + parse_elapsed: started.elapsed(), + }; + if parsed_sender.send(Ok(batch)).is_err() { break; } } Err(error) => { - let _ = sender.send(Err(error)); + let _ = parsed_sender.send(Err(error)); + break; + } + } + } + }); + + scope.spawn(move || { + for result in parsed_receiver { + let batch = match result { + Ok(batch) => batch, + Err(error) => { + let _ = packed_sender.send(Err(WorkspaceError::from(error))); + break; + } + }; + let glyphs = match pack_glyph_batch(batch.glyphs) { + Ok(glyphs) => glyphs, + Err(error) => { + let _ = packed_sender.send(Err(WorkspaceError::from(error))); break; } + }; + let batch = PackedImportBatch { + glyphs, + glyph_count: batch.glyph_count, + layer_count: batch.layer_count, + parse_elapsed: batch.parse_elapsed, + }; + if packed_sender.send(Ok(batch)).is_err() { + break; } } }); - for result in receiver { - let (glyphs, parse_elapsed) = result?; - let glyph_count = glyphs.len(); - let layer_count = glyphs.iter().map(|glyph| glyph.layers().len()).sum(); - let started = Instant::now(); - writer.write_glyph_batch(&glyphs)?; + for result in packed_receiver { + let batch = result?; + let pack_elapsed = batch.glyphs.pack_elapsed(); + let sqlite_elapsed = writer.write_packed_glyph_batch(batch.glyphs)?; observe(ImportBatchProgress { - glyph_count, - layer_count, - parse_elapsed, - write_elapsed: started.elapsed(), + glyph_count: batch.glyph_count, + layer_count: batch.layer_count, + parse_elapsed: batch.parse_elapsed, + pack_elapsed, + sqlite_elapsed, }); } Ok(()) From 53155a640a56365917dcd67ae7100f1816094a2c Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 20:04:43 +0000 Subject: [PATCH 13/29] Build import indexes after streaming --- crates/shift-store/README.md | 4 +- crates/shift-store/src/schema.rs | 32 ++++++++++++--- crates/shift-store/src/stream_writer.rs | 54 +++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 12fdd617..7ad65d19 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -16,7 +16,7 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se ## Import boundary -`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `write_glyph_batch` canonical-encodes layers with Rayon, then writes directory rows, BLOBs, and component indexes in stable order through one SQLite transaction. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. `finish` is the only stream commit point; dropping an unfinished writer rolls the complete stream back. +`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `pack_glyph_batch` canonical-encodes an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, packing, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. `ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the destination is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL before workspace state is published. Normal edits never use import pragmas. Progress, cancellation, source fingerprinting, and final atomic destination installation remain workspace responsibilities. @@ -41,7 +41,7 @@ src/ schema.rs # pre-release version-1 baseline font_state.rs # eager metadata/directory and explicit full materialization packed_layer.rs # bounded BLOB fetch/replace and reference-index checks - stream_writer.rs # bounded Rayon pack plus single-transaction SQLite sink + stream_writer.rs # independently pipelined Rayon pack plus single-transaction SQLite sink write_mode.rs # shared insert/upsert policy for canonical write paths change_set.rs # transactional workspace changes and one final touched-layer write workspace_state.rs diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index 538e308f..a2307a53 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -1,5 +1,21 @@ +use rusqlite::Transaction; + use crate::StoreError; +const DEFER_IMPORT_INDEXES: &str = r#" +DROP INDEX IF EXISTS glyphs_name_idx; +DROP INDEX IF EXISTS glyph_layers_glyph_id_idx; +DROP INDEX IF EXISTS glyph_layers_source_id_idx; +DROP INDEX IF EXISTS glyph_components_base_glyph_id_idx; +"#; + +const RESTORE_IMPORT_INDEXES: &str = r#" +CREATE INDEX glyphs_name_idx ON glyphs(name); +CREATE INDEX glyph_layers_glyph_id_idx ON glyph_layers(glyph_id); +CREATE INDEX glyph_layers_source_id_idx ON glyph_layers(source_id); +CREATE INDEX glyph_components_base_glyph_id_idx ON glyph_components(base_glyph_id); +"#; + pub(crate) const SCHEMA_V1: &str = r#" CREATE TABLE IF NOT EXISTS font_info ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -92,9 +108,6 @@ CREATE TABLE IF NOT EXISTS glyph_unicodes ( FOREIGN KEY (glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS glyph_unicodes_glyph_id_idx -ON glyph_unicodes(glyph_id); - CREATE TABLE IF NOT EXISTS glyph_layers ( id TEXT PRIMARY KEY, glyph_id TEXT NOT NULL, @@ -131,9 +144,6 @@ CREATE TABLE IF NOT EXISTS glyph_components ( CREATE UNIQUE INDEX IF NOT EXISTS glyph_components_layer_order_unique ON glyph_components(layer_id, order_index); -CREATE INDEX IF NOT EXISTS glyph_components_layer_id_idx -ON glyph_components(layer_id); - CREATE INDEX IF NOT EXISTS glyph_components_base_glyph_id_idx ON glyph_components(base_glyph_id); @@ -250,6 +260,16 @@ CREATE TABLE IF NOT EXISTS workspace_state ( pub(crate) const SCHEMA_VERSION: i64 = 1; +pub(crate) fn defer_import_indexes(tx: &Transaction<'_>) -> Result<(), StoreError> { + tx.execute_batch(DEFER_IMPORT_INDEXES)?; + Ok(()) +} + +pub(crate) fn restore_import_indexes(tx: &Transaction<'_>) -> Result<(), StoreError> { + tx.execute_batch(RESTORE_IMPORT_INDEXES)?; + Ok(()) +} + /// Creates the baseline schema and stamps `user_version`. /// /// Pre-release policy: the app has not shipped, so schema changes edit the diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index f8969464..cfab7013 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -8,6 +8,7 @@ use crate::{ LayerBatchTiming, PackedGlyphBatch, ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, packed_layer::store_packed_layer_in_tx, + schema::{defer_import_indexes, restore_import_indexes}, types::PackedGlyph, write_mode::WriteMode, }; @@ -30,6 +31,7 @@ impl ShiftStore { ) -> Result, StoreError> { let tx = self.conn.transaction()?; replace_font_header_in_tx(&tx, font_header)?; + defer_import_indexes(&tx)?; Ok(LayerStreamWriter { tx, next_glyph_order: 0, @@ -123,6 +125,7 @@ impl LayerStreamWriter<'_> { /// Atomically publishes every glyph and layer written by this stream. pub fn finish(self) -> Result<(), StoreError> { + restore_import_indexes(&self.tx)?; self.tx.commit()?; Ok(()) } @@ -143,3 +146,54 @@ fn pack_glyph_layers( .collect::, font::PackedLayerError>>() .map_err(Into::into) } + +#[cfg(test)] +mod tests { + use super::*; + + const DEFERRED_INDEXES: [&str; 4] = [ + "glyphs_name_idx", + "glyph_layers_glyph_id_idx", + "glyph_layers_source_id_idx", + "glyph_components_base_glyph_id_idx", + ]; + + #[test] + fn stream_indexes_are_transactionally_deferred_and_restored() { + let font = font::test_support::sample_font(); + let mut store = ShiftStore::open_memory_for_test().unwrap(); + for name in DEFERRED_INDEXES { + assert!(index_exists(&store.conn, name)); + } + assert!(!index_exists(&store.conn, "glyph_unicodes_glyph_id_idx")); + assert!(!index_exists(&store.conn, "glyph_components_layer_id_idx")); + + { + let writer = store.font_stream_writer(&font).unwrap(); + for name in DEFERRED_INDEXES { + assert!(!index_exists(&writer.tx, name)); + } + } + for name in DEFERRED_INDEXES { + assert!(index_exists(&store.conn, name)); + } + + let mut writer = store.font_stream_writer(&font).unwrap(); + for glyph in font.glyphs() { + writer.write_glyph(glyph).unwrap(); + } + writer.finish().unwrap(); + for name in DEFERRED_INDEXES { + assert!(index_exists(&store.conn, name)); + } + } + + fn index_exists(conn: &rusqlite::Connection, name: &str) -> bool { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'index' AND name = ?1)", + [name], + |row| row.get(0), + ) + .unwrap() + } +} From 59b455c98c3c35348d5179993988e8401c07a692 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 20:48:46 +0000 Subject: [PATCH 14/29] Reduce canonical layer encoder allocations --- crates/shift-glyph-codec/src/layer.rs | 65 ++++++++++++++++++++------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs index f325e574..c02d9d0e 100644 --- a/crates/shift-glyph-codec/src/layer.rs +++ b/crates/shift-glyph-codec/src/layer.rs @@ -1029,7 +1029,33 @@ pub fn pack_layer(layer: &GlyphLayer) -> Result Result, LayerCodecError> } #[derive(Default)] -struct StringPool { - values: Vec, - indexes: HashMap, +struct StringPool<'a> { + values: Vec<&'a str>, + indexes: HashMap<&'a str, u32>, } -impl StringPool { - fn intern(&mut self, value: &str) -> Result { +impl<'a> StringPool<'a> { + fn with_capacity(capacity: usize) -> Self { + Self { + values: Vec::with_capacity(capacity), + indexes: HashMap::with_capacity(capacity), + } + } + + fn intern(&mut self, value: &'a str) -> Result { if let Some(index) = self.indexes.get(value) { return Ok(*index); } @@ -1438,8 +1471,8 @@ impl StringPool { )?; let index = u32::try_from(self.values.len()).map_err(|_| LayerCodecError::LengthOverflow)?; - self.values.push(value.to_owned()); - self.indexes.insert(value.to_owned(), index); + self.values.push(value); + self.indexes.insert(value, index); Ok(index) } fn index(&self, value: &str) -> u32 { @@ -1676,9 +1709,9 @@ fn decode_lib_value( } } -fn gather_map_strings( - values: &BTreeMap, - strings: &mut StringPool, +fn gather_map_strings<'a>( + values: &'a BTreeMap, + strings: &mut StringPool<'a>, depth: usize, count: &mut usize, ) -> Result<(), LayerCodecError> { @@ -1690,9 +1723,9 @@ fn gather_map_strings( } Ok(()) } -fn gather_value_strings( - value: &LayerLibValue, - strings: &mut StringPool, +fn gather_value_strings<'a>( + value: &'a LayerLibValue, + strings: &mut StringPool<'a>, depth: usize, count: &mut usize, ) -> Result<(), LayerCodecError> { @@ -1724,7 +1757,7 @@ fn gather_value_strings( fn encode_map( bytes: &mut Vec, values: &BTreeMap, - strings: &StringPool, + strings: &StringPool<'_>, depth: usize, count: &mut usize, ) -> Result<(), LayerCodecError> { @@ -1739,7 +1772,7 @@ fn encode_map( fn encode_lib_value( bytes: &mut Vec, value: &LayerLibValue, - strings: &StringPool, + strings: &StringPool<'_>, depth: usize, count: &mut usize, ) -> Result<(), LayerCodecError> { From b7d3ec2f62dbdba96433e86dd9f0896263ee507d Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Tue, 28 Jul 2026 20:49:16 +0000 Subject: [PATCH 15/29] Preserve TrueType quadratic layers --- crates/shift-backends/docs/DOCS.md | 4 +- crates/shift-backends/src/binary/reader.rs | 144 +++++------------- crates/shift-backends/tests/loading.rs | 29 +++- crates/shift-backends/tests/round_trip/ufo.rs | 27 +++- 4 files changed, 81 insertions(+), 123 deletions(-) diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index 48242f7a..e40831fa 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -24,6 +24,8 @@ Font format backends that convert between on-disk font files and the `Font` IR u **Architecture Invariant:** Compiled-font streaming enumerates `maxp` glyph IDs, not only `cmap` mappings. Unencoded glyphs receive their `post`/CFF name or a synthesized `gidN` name, and all Unicode mappings for a glyph share one authored glyph identity. WHY: `cmap` is character lookup, not the complete glyph directory. +**Architecture Invariant:** TrueType quadratic segments remain one `OffCurve` control plus one `QCurve` endpoint in the authored layer. Closing qcurve endpoints transfer their type to the wrapped start point. CFF cubic segments remain cubic. The bridge may project a qcurve endpoint as on-curve because clients infer the quadratic from its single control; canonical storage and source export retain the distinction. WHY: lifting every TrueType quadratic to cubic adds a point and derived coordinates to every segment, inflating canonical documents without adding information. + **Architecture Invariant:** Designspace source locations are imported as complete design-space locations. An omitted source dimension resolves to that axis's user-space default mapped into design space; default-source selection compares against that same completed mapped location and never silently substitutes the first source. A `layer` attribute only selects where a source's outlines live and does not make it ineligible to be the default. WHY: these are the reference designspaceLib semantics, and mixing user defaults with design coordinates corrupts interpolation bases. ## Codemap @@ -78,7 +80,7 @@ src/ **Multi-layer support:** `UfoReader` iterates all norad layers. The `public.default` layer maps to the IR's default layer; other layers are added via `Font::add_layer`. Glyphs in non-default layers are merged into existing `Glyph` entries when the glyph already exists from another layer. -**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID and groups all `cmap` values by glyph. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. +**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID, groups all `cmap` values by glyph, and preserves TrueType quadratics instead of expanding them to cubic control pairs. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. **Glyphs-format specifics:** `GlyphsReader` also extracts axes, sources, and per-master locations -- data that UFO does not natively represent. Kerning group membership is derived from per-glyph `right_kern`/`left_kern` fields and normalized to `public.kern1.*`/`public.kern2.*` conventions. diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index 9c30f7f0..79f6214e 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -55,25 +55,14 @@ impl OutlinePen for ShiftPen { .add_point(x as f64, y as f64, PointType::OnCurve, false); } - /// Binary imports produce cubic-only IR: the wire and renderer layers do - /// not support quadratic segments, so every TrueType quadratic is lifted - /// to its exact cubic equivalent (mathematically lossless — every - /// quadratic is a cubic with c1 = q0 + 2/3(q1 - q0), c2 = q2 + 2/3(q1 - q2)). + /// Preserves the source quadratic as one control and one qcurve endpoint. + /// The canonical layer codec retains this distinction; the wire projects + /// the endpoint as on-curve, which still lets clients infer one-control + /// quadratic segments without cubic expansion. fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { let contour = self.current_contour(); - let q0 = contour - .last_point() - .expect("quad_to requires a current point"); - let (q0x, q0y) = (q0.x(), q0.y()); - let (q1x, q1y) = (cx0 as f64, cy0 as f64); - let (q2x, q2y) = (x as f64, y as f64); - let c1x = q0x + 2.0 / 3.0 * (q1x - q0x); - let c1y = q0y + 2.0 / 3.0 * (q1y - q0y); - let c2x = q2x + 2.0 / 3.0 * (q1x - q2x); - let c2y = q2y + 2.0 / 3.0 * (q1y - q2y); - contour.add_point(c1x, c1y, PointType::OffCurve, false); - contour.add_point(c2x, c2y, PointType::OffCurve, false); - contour.add_point(q2x, q2y, PointType::OnCurve, false); + contour.add_point(cx0 as f64, cy0 as f64, PointType::OffCurve, false); + contour.add_point(x as f64, y as f64, PointType::QCurve, false); } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { @@ -95,7 +84,11 @@ impl OutlinePen for ShiftPen { let first = contour.first_point().expect("contour is non-empty"); let last = contour.last_point().expect("contour is non-empty"); if last.is_on_curve() && last.x() == first.x() && last.y() == first.y() { + let closes_quadratic = last.point_type() == PointType::QCurve; contour.points_mut().pop(); + if closes_quadratic { + contour.points_mut()[0].set_point_type(PointType::QCurve); + } } } contour.close(); @@ -432,7 +425,7 @@ fn localized_string(font: &FontRef<'_>, id: StringId) -> Option { #[cfg(test)] mod tests { use super::*; - use shift_font::Point; + use shift_font::CurveSegment; use skrifa::outline::pen::PathElement; use std::path::PathBuf; @@ -445,34 +438,12 @@ mod tests { .join("fixtures/fonts/mutatorsans/MutatorSans.ttf") } - fn quad_at(q0: (f64, f64), q1: (f64, f64), q2: (f64, f64), t: f64) -> (f64, f64) { - let u = 1.0 - t; - ( - u * u * q0.0 + 2.0 * u * t * q1.0 + t * t * q2.0, - u * u * q0.1 + 2.0 * u * t * q1.1 + t * t * q2.1, - ) - } - - fn cubic_at( - p0: (f64, f64), - c1: (f64, f64), - c2: (f64, f64), - p3: (f64, f64), - t: f64, - ) -> (f64, f64) { - let u = 1.0 - t; - ( - u * u * u * p0.0 + 3.0 * u * u * t * c1.0 + 3.0 * u * t * t * c2.0 + t * t * t * p3.0, - u * u * u * p0.1 + 3.0 * u * u * t * c1.1 + 3.0 * u * t * t * c2.1 + t * t * t * p3.1, - ) - } - fn point_types(contour: &Contour) -> Vec { contour.points().iter().map(|p| p.point_type()).collect() } #[test] - fn quad_lifted_to_geometrically_identical_cubic() { + fn quad_preserves_control_and_endpoint() { let mut pen = ShiftPen::default(); pen.move_to(10.0, 20.0); pen.quad_to(50.0, 90.0, 100.0, 20.0); @@ -481,28 +452,10 @@ mod tests { let points = contours[0].points(); assert_eq!( point_types(&contours[0]), - vec![ - PointType::OnCurve, - PointType::OffCurve, - PointType::OffCurve, - PointType::OnCurve - ] + vec![PointType::OnCurve, PointType::OffCurve, PointType::QCurve] ); - - let q0 = (10.0, 20.0); - let q1 = (50.0, 90.0); - let q2 = (100.0, 20.0); - let c1 = (points[1].x(), points[1].y()); - let c2 = (points[2].x(), points[2].y()); - for step in 0..=20 { - let t = step as f64 / 20.0; - let expected = quad_at(q0, q1, q2, t); - let actual = cubic_at(q0, c1, c2, q2, t); - assert!( - (expected.0 - actual.0).abs() < 1e-9 && (expected.1 - actual.1).abs() < 1e-9, - "lifted cubic diverges from quad at t={t}: {expected:?} vs {actual:?}" - ); - } + assert_eq!((points[1].x(), points[1].y()), (50.0, 90.0)); + assert_eq!((points[2].x(), points[2].y()), (100.0, 20.0)); } #[test] @@ -518,13 +471,8 @@ mod tests { assert!(contour.is_closed()); assert_eq!( point_types(contour), - vec![ - PointType::OnCurve, - PointType::OnCurve, - PointType::OffCurve, - PointType::OffCurve - ], - "closing curve's explicit end point should be dropped; the closed contour wraps to the start" + vec![PointType::QCurve, PointType::OnCurve, PointType::OffCurve], + "closing qcurve endpoint should move to the wrapped start point" ); } @@ -555,15 +503,14 @@ mod tests { vec![ PointType::OnCurve, PointType::OffCurve, - PointType::OffCurve, - PointType::OnCurve, + PointType::QCurve, PointType::OnCurve ] ); } #[test] - fn imported_cubics_match_source_quadratics() { + fn imported_quadratics_match_source_segments() { let path = mutatorsans_ttf_path(); let bytes = std::fs::read(&path).expect("MutatorSans.ttf fixture should exist"); let font_ref = FontRef::new(&bytes).unwrap(); @@ -594,21 +541,18 @@ mod tests { .next() .expect("glyph should have a layer"); - let mut cubic_segments: Vec<[(f64, f64); 4]> = Vec::new(); - for contour in layer.contours_iter() { - let points: Vec<&Point> = contour.points().iter().collect(); - let len = points.len(); - for i in 0..len { - let window: Vec<&Point> = (0..4).map(|offset| points[(i + offset) % len]).collect(); - if window[0].is_on_curve() - && window[1].point_type() == PointType::OffCurve - && window[2].point_type() == PointType::OffCurve - && window[3].is_on_curve() - { - cubic_segments.push([0, 1, 2, 3].map(|p| (window[p].x(), window[p].y()))); - } - } - } + let quadratics = layer + .contours_iter() + .flat_map(|contour| contour.segments()) + .filter_map(|segment| match segment { + CurveSegment::Quad(start, control, end) => Some([ + (start.x(), start.y()), + (control.x(), control.y()), + (end.x(), end.y()), + ]), + CurveSegment::Line(..) | CurveSegment::Cubic(..) => None, + }) + .collect::>(); let mut current = (0.0, 0.0); let mut quads_checked = 0; @@ -618,27 +562,13 @@ mod tests { current = (x as f64, y as f64); } PathElement::QuadTo { cx0, cy0, x, y } => { - let q0 = current; - let q1 = (cx0 as f64, cy0 as f64); - let q2 = (x as f64, y as f64); - let segment = cubic_segments - .iter() - .find(|[p0, _, _, p3]| *p0 == q0 && *p3 == q2) - .unwrap_or_else(|| { - panic!("no imported cubic segment from {q0:?} to {q2:?}") - }); - for step in 1..8 { - let t = step as f64 / 8.0; - let expected = quad_at(q0, q1, q2, t); - let actual = cubic_at(segment[0], segment[1], segment[2], segment[3], t); - assert!( - (expected.0 - actual.0).abs() < 1e-9 - && (expected.1 - actual.1).abs() < 1e-9, - "imported cubic diverges from source quad at t={t}" - ); - } + let expected = [current, (cx0 as f64, cy0 as f64), (x as f64, y as f64)]; + assert!( + quadratics.contains(&expected), + "missing imported quadratic {expected:?}" + ); quads_checked += 1; - current = q2; + current = expected[2]; } PathElement::CurveTo { x, y, .. } => { current = (x as f64, y as f64); diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index dc82f3b4..eb223a4c 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -285,7 +285,7 @@ fn loads_binary_variable_axes_and_named_instances() { assert_eq!(regular.postscript_name(), Some("HostGrotesk-Regular")); } -fn assert_cubic_point_runs(contour: &Contour, context: &str) { +fn assert_curve_point_runs(contour: &Contour, context: &str) { let points = contour.points(); assert!(!points.is_empty(), "empty contour in {context}"); assert!( @@ -304,16 +304,29 @@ fn assert_cubic_point_runs(contour: &Contour, context: &str) { ); off_run = 0; } - other => panic!("unexpected point type {other:?} in {context}"), + PointType::QCurve => { + assert_eq!( + off_run, 1, + "qcurve point preceded by {off_run} off-curves in {context}" + ); + off_run = 0; + } } } if contour.is_closed() { - assert!( - off_run == 0 || off_run == 2, - "closing segment has {off_run} off-curves in {context}" - ); let first = &points[0]; + match first.point_type() { + PointType::QCurve => assert_eq!( + off_run, 1, + "closing qcurve has {off_run} off-curves in {context}" + ), + PointType::OnCurve => assert!( + off_run == 0 || off_run == 2, + "closing segment has {off_run} off-curves in {context}" + ), + PointType::OffCurve => unreachable!("first point is known to be on-curve"), + } let last = &points[points.len() - 1]; assert!( points.len() == 1 @@ -329,7 +342,7 @@ fn assert_cubic_point_runs(contour: &Contour, context: &str) { } #[test] -fn binary_import_produces_valid_cubic_point_runs() { +fn binary_import_produces_valid_curve_point_runs() { for path in [mutatorsans_ttf_path(), mutatorsans_otf_path()] { let font = load_font(&path); let mut curve_contours = 0; @@ -337,7 +350,7 @@ fn binary_import_produces_valid_cubic_point_runs() { for layer in glyph.layers().values() { for contour in layer.contours_iter() { let context = format!("glyph '{}' in {}", glyph.name(), path.display()); - assert_cubic_point_runs(contour, &context); + assert_curve_point_runs(contour, &context); if contour .points() .iter() diff --git a/crates/shift-backends/tests/round_trip/ufo.rs b/crates/shift-backends/tests/round_trip/ufo.rs index 96eb1ced..1573ee65 100644 --- a/crates/shift-backends/tests/round_trip/ufo.rs +++ b/crates/shift-backends/tests/round_trip/ufo.rs @@ -532,7 +532,8 @@ fn ttf_import_saves_spec_valid_ufo_curves() { let norad_font = norad::Font::load(&output_path) .expect("norad should parse every glif written from a TTF import"); - let mut curve_points = 0; + let mut cubic_points = 0; + let mut quadratic_points = 0; for layer in norad_font.layers.iter() { for glyph in layer.iter() { for contour in &glyph.contours { @@ -556,24 +557,32 @@ fn ttf_import_saves_spec_valid_ufo_curves() { "curve point in glyph '{}' is not preceded by exactly two off-curves", glyph.name() ); - curve_points += 1; + cubic_points += 1; } norad::PointType::OffCurve => { let next = &points[(index + 1) % points.len()]; assert!( matches!( next.typ, - norad::PointType::OffCurve | norad::PointType::Curve + norad::PointType::OffCurve + | norad::PointType::Curve + | norad::PointType::QCurve ), - "off-curve in glyph '{}' is not part of a cubic curve segment", + "off-curve in glyph '{}' is not part of a curve segment", glyph.name() ); } norad::PointType::QCurve => { - panic!( - "TTF import should not produce qcurve points (glyph '{}')", + let len = points.len(); + let prev = &points[(index + len - 1) % len]; + let prev_prev = &points[(index + len - 2) % len]; + assert!( + prev.typ == norad::PointType::OffCurve + && prev_prev.typ != norad::PointType::OffCurve, + "qcurve point in glyph '{}' is not preceded by exactly one off-curve", glyph.name() ); + quadratic_points += 1; } norad::PointType::Line | norad::PointType::Move => {} } @@ -582,7 +591,11 @@ fn ttf_import_saves_spec_valid_ufo_curves() { } } assert!( - curve_points > 0, + cubic_points + quadratic_points > 0, "TTF import round trip should contain curve segments" ); + assert!( + quadratic_points > 0, + "TrueType quadratic segments should remain qcurves after UFO export" + ); } From 30b9ef6c0de8f5fb325eef07ac6365c09fc60cd0 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Wed, 29 Jul 2026 05:56:02 +0000 Subject: [PATCH 16/29] Derive stable compiled outline identities --- crates/shift-backends/docs/DOCS.md | 4 +- crates/shift-backends/src/binary/reader.rs | 125 +++++++++++++++++---- 2 files changed, 107 insertions(+), 22 deletions(-) diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index e40831fa..088fe820 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -24,6 +24,8 @@ Font format backends that convert between on-disk font files and the `Font` IR u **Architecture Invariant:** Compiled-font streaming enumerates `maxp` glyph IDs, not only `cmap` mappings. Unencoded glyphs receive their `post`/CFF name or a synthesized `gidN` name, and all Unicode mappings for a glyph share one authored glyph identity. WHY: `cmap` is character lookup, not the complete glyph directory. +**Architecture Invariant:** Compiled-font contour and point identities are deterministic positions within one glyph's emitted outline: `contour_b{glyph-id-hex}_{contour-index-hex}` and `point_b{glyph-id-hex}_{point-index-hex}`. Counters are monotonic from zero; dropping an explicit closing endpoint does not reuse its consumed point index. WHY: compiled fonts have stable glyph/outline order but no Shift identities, and random IDs make equivalent re-imports differ while injecting incompressible entropy into canonical payloads. + **Architecture Invariant:** TrueType quadratic segments remain one `OffCurve` control plus one `QCurve` endpoint in the authored layer. Closing qcurve endpoints transfer their type to the wrapped start point. CFF cubic segments remain cubic. The bridge may project a qcurve endpoint as on-curve because clients infer the quadratic from its single control; canonical storage and source export retain the distinction. WHY: lifting every TrueType quadratic to cubic adds a point and derived coordinates to every segment, inflating canonical documents without adding information. **Architecture Invariant:** Designspace source locations are imported as complete design-space locations. An omitted source dimension resolves to that axis's user-space default mapped into design space; default-source selection compares against that same completed mapped location and never silently substitutes the first source. A `layer` attribute only selects where a source's outlines live and does not make it ineligible to be the default. WHY: these are the reference designspaceLib semantics, and mixing user defaults with design coordinates corrupts interpolation bases. @@ -80,7 +82,7 @@ src/ **Multi-layer support:** `UfoReader` iterates all norad layers. The `public.default` layer maps to the IR's default layer; other layers are added via `Font::add_layer`. Glyphs in non-default layers are merged into existing `Glyph` entries when the glyph already exists from another layer. -**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID, groups all `cmap` values by glyph, and preserves TrueType quadratics instead of expanding them to cubic control pairs. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. +**Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID, groups all `cmap` values by glyph, deterministically derives contour/point identities from emitted positions, and preserves TrueType quadratics instead of expanding them to cubic control pairs. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. **Glyphs-format specifics:** `GlyphsReader` also extracts axes, sources, and per-master locations -- data that UFO does not natively represent. Kerning group membership is derived from per-glyph `right_kern`/`left_kern` fields and normalized to `public.kern1.*`/`public.kern2.*` conventions. diff --git a/crates/shift-backends/src/binary/reader.rs b/crates/shift-backends/src/binary/reader.rs index 79f6214e..51e28947 100644 --- a/crates/shift-backends/src/binary/reader.rs +++ b/crates/shift-backends/src/binary/reader.rs @@ -6,8 +6,8 @@ use crate::{ }; use rayon::prelude::*; use shift_font::{ - Axis, Contour, Font, Glyph, GlyphId as ShiftGlyphId, GlyphLayer, GlyphName, LayerId, Location, - MetricKind, NamedInstance, PointType, SourceId, + Axis, Contour, ContourId, Font, Glyph, GlyphId as ShiftGlyphId, GlyphLayer, GlyphName, LayerId, + Location, MetricKind, NamedInstance, PointId, PointType, SourceId, }; use skrifa::{ outline::{DrawSettings, OutlineGlyphCollection, OutlinePen}, @@ -24,12 +24,35 @@ pub fn read_font_file(path: &str) -> FormatBackendResult { collect_streamed_font(header, &mut stream) } -#[derive(Default)] struct ShiftPen { + glyph_id: u32, + next_contour_index: usize, + next_point_index: usize, contours: Vec, } impl ShiftPen { + fn new(glyph_id: GlyphId) -> Self { + Self { + glyph_id: glyph_id.to_u32(), + next_contour_index: 0, + next_point_index: 0, + contours: Vec::new(), + } + } + + fn next_contour_id(&mut self) -> ContourId { + let index = self.next_contour_index; + self.next_contour_index += 1; + ContourId::from_raw(format!("b{:x}_{index:x}", self.glyph_id)) + } + + fn next_point_id(&mut self) -> PointId { + let index = self.next_point_index; + self.next_point_index += 1; + PointId::from_raw(format!("b{:x}_{index:x}", self.glyph_id)) + } + /// Returns the contour currently being built. /// Panics if called before `move_to` — skrifa guarantees `move_to` is called first. fn current_contour(&mut self) -> &mut Contour { @@ -45,14 +68,27 @@ impl ShiftPen { impl OutlinePen for ShiftPen { fn move_to(&mut self, x: f32, y: f32) { - self.contours.push(Contour::new()); - self.current_contour() - .add_point(x as f64, y as f64, PointType::OnCurve, false); + let contour_id = self.next_contour_id(); + self.contours.push(Contour::with_id(contour_id)); + let point_id = self.next_point_id(); + self.current_contour().add_point_with_id( + point_id, + x as f64, + y as f64, + PointType::OnCurve, + false, + ); } fn line_to(&mut self, x: f32, y: f32) { - self.current_contour() - .add_point(x as f64, y as f64, PointType::OnCurve, false); + let point_id = self.next_point_id(); + self.current_contour().add_point_with_id( + point_id, + x as f64, + y as f64, + PointType::OnCurve, + false, + ); } /// Preserves the source quadratic as one control and one qcurve endpoint. @@ -60,18 +96,39 @@ impl OutlinePen for ShiftPen { /// the endpoint as on-curve, which still lets clients infer one-control /// quadratic segments without cubic expansion. fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + let control_id = self.next_point_id(); + let endpoint_id = self.next_point_id(); let contour = self.current_contour(); - contour.add_point(cx0 as f64, cy0 as f64, PointType::OffCurve, false); - contour.add_point(x as f64, y as f64, PointType::QCurve, false); + contour.add_point_with_id( + control_id, + cx0 as f64, + cy0 as f64, + PointType::OffCurve, + false, + ); + contour.add_point_with_id(endpoint_id, x as f64, y as f64, PointType::QCurve, false); } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.current_contour() - .add_point(cx0 as f64, cy0 as f64, PointType::OffCurve, false); - self.current_contour() - .add_point(cx1 as f64, cy1 as f64, PointType::OffCurve, false); - self.current_contour() - .add_point(x as f64, y as f64, PointType::OnCurve, false); + let control_0_id = self.next_point_id(); + let control_1_id = self.next_point_id(); + let endpoint_id = self.next_point_id(); + let contour = self.current_contour(); + contour.add_point_with_id( + control_0_id, + cx0 as f64, + cy0 as f64, + PointType::OffCurve, + false, + ); + contour.add_point_with_id( + control_1_id, + cx1 as f64, + cy1 as f64, + PointType::OffCurve, + false, + ); + contour.add_point_with_id(endpoint_id, x as f64, y as f64, PointType::OnCurve, false); } /// Closes the current contour. skrifa ends contours whose final segment @@ -323,7 +380,7 @@ fn glyph_from_skrifa( if let Some(outline) = outlines.get(record.raw_id) { let settings = DrawSettings::unhinted(Size::unscaled(), LocationRef::default()); - let mut pen = ShiftPen::default(); + let mut pen = ShiftPen::new(record.raw_id); outline.draw(settings, &mut pen).map_err(|error| { FormatBackendError::Binary(format!( "failed to draw outline for glyph {}: {error}", @@ -442,9 +499,35 @@ mod tests { contour.points().iter().map(|p| p.point_type()).collect() } + fn deterministic_pen_contours() -> Vec { + let mut pen = ShiftPen::new(GlyphId::new(0x2a)); + pen.move_to(0.0, 0.0); + pen.line_to(100.0, 0.0); + pen.quad_to(100.0, 100.0, 0.0, 0.0); + pen.close(); + pen.move_to(10.0, 20.0); + pen.contours() + } + + #[test] + fn binary_pen_mints_deterministic_positional_ids() { + let contours = deterministic_pen_contours(); + assert_eq!(contours, deterministic_pen_contours()); + assert_eq!(contours[0].id().as_str(), "contour_b2a_0"); + assert_eq!(contours[1].id().as_str(), "contour_b2a_1"); + assert_eq!(contours[0].points()[0].id().as_str(), "point_b2a_0"); + assert_eq!(contours[0].points()[1].id().as_str(), "point_b2a_1"); + assert_eq!(contours[0].points()[2].id().as_str(), "point_b2a_2"); + assert_eq!( + contours[1].points()[0].id().as_str(), + "point_b2a_4", + "the removed closing endpoint keeps its consumed positional index" + ); + } + #[test] fn quad_preserves_control_and_endpoint() { - let mut pen = ShiftPen::default(); + let mut pen = ShiftPen::new(GlyphId::new(0x2a)); pen.move_to(10.0, 20.0); pen.quad_to(50.0, 90.0, 100.0, 20.0); @@ -460,7 +543,7 @@ mod tests { #[test] fn close_drops_duplicate_start_point_from_closing_curve() { - let mut pen = ShiftPen::default(); + let mut pen = ShiftPen::new(GlyphId::new(0x2a)); pen.move_to(0.0, 0.0); pen.line_to(100.0, 0.0); pen.quad_to(100.0, 100.0, 0.0, 0.0); @@ -478,7 +561,7 @@ mod tests { #[test] fn close_keeps_distinct_last_point() { - let mut pen = ShiftPen::default(); + let mut pen = ShiftPen::new(GlyphId::new(0x2a)); pen.move_to(0.0, 0.0); pen.line_to(100.0, 0.0); pen.line_to(100.0, 100.0); @@ -491,7 +574,7 @@ mod tests { #[test] fn line_after_quad_composes() { - let mut pen = ShiftPen::default(); + let mut pen = ShiftPen::new(GlyphId::new(0x2a)); pen.move_to(0.0, 0.0); pen.quad_to(50.0, 100.0, 100.0, 0.0); pen.line_to(200.0, 0.0); From cf7c8c047ae8a7b86d0867abee7d3e3b0110c891 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 07:33:10 +0000 Subject: [PATCH 17/29] Compress canonical glyph layer storage --- Cargo.lock | 100 ++++- crates/shift-store/Cargo.toml | 2 + crates/shift-store/README.md | 22 +- crates/shift-store/src/error.rs | 16 +- crates/shift-store/src/font_state.rs | 29 +- crates/shift-store/src/lib.rs | 6 +- crates/shift-store/src/packed_layer.rs | 384 ++++++++++++++---- crates/shift-store/src/schema.rs | 9 +- crates/shift-store/src/stored_layer.rs | 204 ++++++++++ crates/shift-store/src/stream_writer.rs | 41 +- crates/shift-store/src/types.rs | 33 +- .../examples/profile_streaming_import.rs | 15 +- crates/shift-workspace/src/import_pipeline.rs | 3 + .../shift-workspace/tests/workspace_test.rs | 51 +++ docs/architecture/index.md | 2 +- 15 files changed, 801 insertions(+), 116 deletions(-) create mode 100644 crates/shift-store/src/stored_layer.rs diff --git a/Cargo.lock b/Cargo.lock index b21a0797..882e2f37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,20 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -326,6 +340,8 @@ version = "1.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ad45f4f74e4e20eaa392913b7b33a7091c87e59628f4dd27888205ad888843c" dependencies = [ + "jobserver", + "libc", "shlex", ] @@ -432,6 +448,12 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.11.0" @@ -456,6 +478,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1030,10 +1061,21 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gimli" version = "0.32.3" @@ -1308,6 +1350,16 @@ dependencies = [ "syn", ] +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1946,6 +1998,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2228,7 +2286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2297,7 +2355,7 @@ name = "shift-font" version = "0.1.0" dependencies = [ "fontdrasil", - "getrandom", + "getrandom 0.3.3", "indexmap", "kurbo 0.13.0", "linesweeper", @@ -2334,7 +2392,7 @@ dependencies = [ name = "shift-source" version = "0.1.0" dependencies = [ - "getrandom", + "getrandom 0.3.3", "serde", "serde_json", "shift-font", @@ -2347,12 +2405,14 @@ dependencies = [ name = "shift-store" version = "0.1.0" dependencies = [ + "blake3", "rayon", "rusqlite", "serde_json", "shift-font", "tempfile", "thiserror", + "zstd", ] [[package]] @@ -2508,7 +2568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -2729,7 +2789,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ - "getrandom", + "getrandom 0.3.3", "js-sys", "wasm-bindgen", ] @@ -3406,3 +3466,31 @@ dependencies = [ "memchr", "thiserror", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml index e8c40f8d..9877486b 100644 --- a/crates/shift-store/Cargo.toml +++ b/crates/shift-store/Cargo.toml @@ -5,10 +5,12 @@ edition = "2024" [dependencies] shift-font = { workspace = true } +blake3 = "1.8" rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } rayon = "1.10" serde_json = "1.0" thiserror = "2" +zstd = "0.13" [dev-dependencies] shift-font = { workspace = true, features = ["test-support"] } diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 7ad65d19..08e491ec 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -7,16 +7,29 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se ## Storage boundary - Font metadata and the glyph/layer directory remain relational and load eagerly. -- Each editable glyph layer is one independently addressable `shift.glyph-layer.v1` BLOB. +- Each editable glyph layer is one independently addressable payload. Its canonical decoded bytes remain `shift.glyph-layer.v1`; the store independently wraps them as `none` or `zstd.v1` without changing authored semantics. - Component dependency rows contain only the earned query index: component ID, owner layer ID, base glyph ID, and ordinal. - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds and validates one payload; `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB of packed bytes. Explicit full-font materialization partitions the directory through that same bounded loader instead of issuing one query sequence per layer. All payload paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB decoded bytes, then decompresses and validates the batch with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict canonical decoding. Explicit full-font materialization partitions the directory through that same bounded loader instead of issuing one query sequence per layer. All payload paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. + +## Outer payload contract + +`glyph_layer_payloads` stores `inner_format`, `compression`, `stored_byte_length`, +`decoded_byte_length`, `decoded_blake3`, and `payload`. `inner_format` remains +`shift.glyph-layer.v1`. Compression is either `none` or `zstd.v1`; `zstd.v1` means one complete +independent Zstandard frame with no dictionary. Level 1 is the current write policy, not a decoding +requirement. Writers retain compressed bytes only when they are smaller than the canonical input. + +`decoded_blake3` is the 32-byte BLAKE3 of the exact canonical bytes. Reads bound both lengths before +fetch/allocation, reject trailing Zstandard data, require the exact decoded length and hash, and +only then invoke strict canonical decoding. A layer replacement may move between `none` and +`zstd.v1` without changing its inner bytes or touching another layer. ## Import boundary -`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `pack_glyph_batch` canonical-encodes an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, packing, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. +`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `pack_glyph_batch` canonical-encodes, BLAKE3-hashes, and independently compresses an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, packing/compression, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. `ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the destination is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL before workspace state is published. Normal edits never use import pragmas. Progress, cancellation, source fingerprinting, and final atomic destination installation remain workspace responsibilities. @@ -41,7 +54,8 @@ src/ schema.rs # pre-release version-1 baseline font_state.rs # eager metadata/directory and explicit full materialization packed_layer.rs # bounded BLOB fetch/replace and reference-index checks - stream_writer.rs # independently pipelined Rayon pack plus single-transaction SQLite sink + stored_layer.rs # independent Zstd/BLAKE3 outer storage representation + stream_writer.rs # independently pipelined Rayon pack/compress plus single-transaction SQLite sink write_mode.rs # shared insert/upsert policy for canonical write paths change_set.rs # transactional workspace changes and one final touched-layer write workspace_state.rs diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 80e5eb09..5cc749f1 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -18,14 +18,26 @@ pub enum StoreError { #[error("unsupported glyph layer payload format: {0}")] UnsupportedLayerFormat(String), + #[error("unsupported glyph layer compression: {0}")] + UnsupportedLayerCompression(String), + + #[error("glyph layer compression failed: {0}")] + LayerCompression(String), + + #[error("glyph layer decompression failed: {0}")] + LayerDecompression(String), + #[error("glyph layer payload has {bytes} bytes; limit is {limit}")] LayerPayloadTooLarge { bytes: u64, limit: u64 }, #[error("glyph layer read batch has {layers} layers; limit is {limit}")] LayerReadBatchTooLarge { layers: usize, limit: usize }, - #[error("glyph layer read batch has {bytes} payload bytes; limit is {limit}")] - LayerReadBatchPayloadTooLarge { bytes: u64, limit: u64 }, + #[error("glyph layer read batch has {bytes} stored bytes; limit is {limit}")] + LayerReadBatchStoredTooLarge { bytes: u64, limit: u64 }, + + #[error("glyph layer read batch has {bytes} decoded bytes; limit is {limit}")] + LayerReadBatchDecodedTooLarge { bytes: u64, limit: u64 }, #[error("glyph layer {layer_id} payload disagrees with its directory: {detail}")] LayerDirectoryMismatch { layer_id: String, detail: String }, diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index a0e85e02..163e8cee 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -365,7 +365,8 @@ fn load_glyphs( let layer_rows = { let mut stmt = conn.prepare( " - SELECT l.id, l.glyph_id, l.source_id, l.width, l.height, p.byte_length + SELECT l.id, l.glyph_id, l.source_id, l.width, l.height, + p.decoded_byte_length FROM glyph_layers AS l LEFT JOIN glyph_layer_payloads AS p ON p.layer_id = l.id ORDER BY l.glyph_id, l.source_id, l.id @@ -387,26 +388,28 @@ fn load_glyphs( if include_layer_payloads { let mut batch = Vec::new(); let mut batch_bytes = 0_u64; - for (layer_id, _, _, _, _, byte_length) in &layer_rows { - let byte_length = byte_length.ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer payload", - id: layer_id.to_string(), - })?; - let byte_length = - u64::try_from(byte_length).map_err(|_| StoreError::LayerDirectoryMismatch { - layer_id: layer_id.to_string(), - detail: format!("negative payload byte length {byte_length}"), + for (layer_id, _, _, _, _, decoded_byte_length) in &layer_rows { + let decoded_byte_length = + decoded_byte_length.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer payload", + id: layer_id.to_string(), })?; + let decoded_byte_length = u64::try_from(decoded_byte_length).map_err(|_| { + StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_string(), + detail: format!("negative decoded byte length {decoded_byte_length}"), + } + })?; if !batch.is_empty() && (batch.len() == crate::MAX_LAYER_READ_BATCH_COUNT - || batch_bytes.saturating_add(byte_length) - > crate::packed_layer::MAX_LAYER_READ_BATCH_PAYLOAD_BYTES) + || batch_bytes.saturating_add(decoded_byte_length) + > crate::MAX_LAYER_READ_BATCH_DECODED_BYTES) { load_layer_batch(conn, &mut batch, &mut loaded_layers)?; batch_bytes = 0; } batch.push(layer_id.clone()); - batch_bytes = batch_bytes.saturating_add(byte_length); + batch_bytes = batch_bytes.saturating_add(decoded_byte_length); } load_layer_batch(conn, &mut batch, &mut loaded_layers)?; } diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index b91b8920..40aa1da4 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -8,6 +8,7 @@ mod packed_layer; mod schema; mod source; mod store; +mod stored_layer; mod stream_writer; mod types; mod workspace_state; @@ -16,7 +17,10 @@ mod write_mode; pub use error::StoreError; pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; -pub use packed_layer::{GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT}; +pub use packed_layer::{ + GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT, + MAX_LAYER_READ_BATCH_DECODED_BYTES, +}; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; pub use stream_writer::{LayerStreamWriter, pack_glyph_batch}; diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index c8fab41d..d3ae21e8 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -1,15 +1,20 @@ use std::collections::{HashMap, HashSet, VecDeque}; +use rayon::prelude::*; use rusqlite::{Connection, OptionalExtension, Transaction, params}; use shift_font as font; use crate::{ - ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, + ShiftStore, StoreError, + stored_layer::{compress_glyph_layer, decompress_glyph_layer, parse_layer_compression}, + types::StoredGlyphLayer, + workspace_state::mark_workspace_dirty_in_tx, + write_mode::WriteMode, }; pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; -pub(crate) const MAX_LAYER_READ_BATCH_PAYLOAD_BYTES: u64 = 256 * 1024 * 1024; +pub const MAX_LAYER_READ_BATCH_DECODED_BYTES: u64 = 256 * 1024 * 1024; const MAX_LAYER_PAYLOAD_BYTES: i64 = font::MAX_LAYER_PAYLOAD_BYTES as i64; /// Relational facts needed to locate a layer without reading its payload. @@ -21,7 +26,8 @@ pub struct GlyphLayerDirectoryEntry { pub name: Option, pub width: f64, pub height: Option, - pub payload_byte_length: u64, + pub stored_byte_length: u64, + pub decoded_byte_length: u64, } impl ShiftStore { @@ -62,7 +68,7 @@ impl ShiftStore { let mut stmt = self.conn.prepare( " SELECT l.id, l.glyph_id, l.source_id, l.name, l.width, l.height, - p.byte_length + p.stored_byte_length, p.decoded_byte_length FROM glyph_layers AS l JOIN glyph_layer_payloads AS p ON p.layer_id = l.id ORDER BY l.glyph_id, l.source_id, l.id @@ -177,32 +183,75 @@ struct DirectoryFacts { source_id: String, width: f64, height: Option, - format: String, - declared_len: i64, - actual_len: i64, + inner_format: String, + compression: String, + stored_byte_length: i64, + decoded_byte_length: i64, + decoded_blake3: Vec, + actual_stored_byte_length: i64, } impl DirectoryFacts { fn validate(&self) -> Result { - if self.format != GLYPH_LAYER_FORMAT { - return Err(StoreError::UnsupportedLayerFormat(self.format.clone())); + if self.inner_format != GLYPH_LAYER_FORMAT { + return Err(StoreError::UnsupportedLayerFormat( + self.inner_format.clone(), + )); } - if self.declared_len != self.actual_len { + parse_layer_compression(&self.compression)?; + if self.stored_byte_length != self.actual_stored_byte_length { return Err(StoreError::LayerDirectoryMismatch { layer_id: self.layer_id.clone(), detail: format!( - "declared byte length {} != actual {}", - self.declared_len, self.actual_len + "declared stored byte length {} != actual {}", + self.stored_byte_length, self.actual_stored_byte_length ), }); } - if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&self.actual_len) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(self.actual_len).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, + for byte_length in [self.stored_byte_length, self.decoded_byte_length] { + if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&byte_length) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(byte_length).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + } + if self.stored_byte_length > self.decoded_byte_length { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "stored byte length {} exceeds decoded byte length {}", + self.stored_byte_length, self.decoded_byte_length + ), }); } - Ok(self.actual_len as u64) + if self.decoded_blake3.len() != 32 { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "decoded BLAKE3 has {} bytes instead of 32", + self.decoded_blake3.len() + ), + }); + } + Ok(self.decoded_byte_length as u64) + } + + fn stored_layer(&self, payload: Vec) -> Result { + self.validate()?; + let decoded_blake3: [u8; 32] = self.decoded_blake3.as_slice().try_into().map_err(|_| { + StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: "decoded BLAKE3 is not 32 bytes".to_string(), + } + })?; + Ok(StoredGlyphLayer { + compression: parse_layer_compression(&self.compression)?, + stored_byte_length: self.stored_byte_length as u64, + decoded_byte_length: self.decoded_byte_length as u64, + decoded_blake3, + bytes: payload, + }) } } @@ -212,9 +261,12 @@ fn map_directory_facts_row(row: &rusqlite::Row<'_>) -> rusqlite::Result MAX_LAYER_READ_BATCH_PAYLOAD_BYTES { - return Err(StoreError::LayerReadBatchPayloadTooLarge { - bytes: batch_payload_bytes, - limit: MAX_LAYER_READ_BATCH_PAYLOAD_BYTES, + if batch_stored_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { + return Err(StoreError::LayerReadBatchStoredTooLarge { + bytes: batch_stored_bytes, + limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, + }); + } + if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { + return Err(StoreError::LayerReadBatchDecodedTooLarge { + bytes: batch_decoded_bytes, + limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, }); } @@ -361,8 +427,7 @@ pub(crate) fn load_glyph_layers_from_conn( row.get::<_, i64>(2)?, )) })?; - let mut layers = Vec::with_capacity(keys.len()); - let mut loaded = HashSet::with_capacity(keys.len()); + let mut payloads = Vec::with_capacity(keys.len()); for row in payload_rows { let (layer_id, payload, actual_len) = row?; let facts = facts @@ -371,26 +436,49 @@ pub(crate) fn load_glyph_layers_from_conn( layer_id: layer_id.clone(), detail: "payload has no directory row".to_string(), })?; - if actual_len != facts.actual_len || payload.len() as i64 != facts.actual_len { + if actual_len != facts.actual_stored_byte_length + || payload.len() as i64 != facts.actual_stored_byte_length + { return Err(StoreError::LayerDirectoryMismatch { layer_id, detail: "payload changed during bounded batch read".to_string(), }); } - let layer = font::unpack_glyph_layer(&payload)?; - let expected_id = font::LayerId::from_raw(layer_id.clone()); - validate_directory_facts( - &expected_id, - &facts.source_id, - facts.width, - facts.height, - &layer, - )?; - loaded.insert(layer_id); - layers.push(layer); + payloads.push((layer_id, payload)); } drop(payload_stmt); + let decoded = payloads + .into_par_iter() + .map(|(layer_id, payload)| { + let facts = facts + .get(&layer_id) + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.clone(), + detail: "payload has no directory row".to_string(), + })?; + let decoded = decompress_glyph_layer(layer_id.as_str(), facts.stored_layer(payload)?)?; + let layer = font::unpack_glyph_layer(&decoded)?; + let expected_id = font::LayerId::from_raw(layer_id.clone()); + validate_directory_facts( + &expected_id, + &facts.source_id, + facts.width, + facts.height, + &layer, + )?; + Ok((layer_id, layer)) + }) + .collect::, StoreError>>()?; + let loaded = decoded + .iter() + .map(|(layer_id, _)| layer_id.clone()) + .collect::>(); + let layers = decoded + .into_iter() + .map(|(_, layer)| layer) + .collect::>(); + for key in &keys { if !loaded.contains(key) { return Err(StoreError::LayerDirectoryMismatch { @@ -442,19 +530,20 @@ pub(crate) fn write_layer_in_tx( layer: &font::GlyphLayer, ) -> Result<(), StoreError> { let packed = font::pack_glyph_layer(layer)?; - store_packed_layer_in_tx(tx, glyph_id, name, layer, &packed, WriteMode::Upsert) + let stored = compress_glyph_layer(packed.as_bytes())?; + store_stored_layer_in_tx(tx, glyph_id, name, layer, &stored, WriteMode::Upsert) } -pub(crate) fn store_packed_layer_in_tx( +pub(crate) fn store_stored_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, name: Option<&font::GlyphName>, layer: &font::GlyphLayer, - packed: &font::PackedGlyphLayer, + stored: &StoredGlyphLayer, mode: WriteMode, ) -> Result<(), StoreError> { - let bytes = packed.as_bytes(); - let byte_length = encoded_len(bytes)?; + let stored_byte_length = encoded_len(stored.stored_byte_length)?; + let decoded_byte_length = encoded_len(stored.decoded_byte_length)?; let layer_sql = match mode { WriteMode::Insert => { "INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" @@ -483,31 +572,42 @@ pub(crate) fn store_packed_layer_in_tx( let payload_sql = match mode { WriteMode::Insert => { - "INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, ?3, ?4)" + "INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" } WriteMode::Upsert => { " - INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) - VALUES (?1, ?2, ?3, ?4) + INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(layer_id) DO UPDATE SET - format = excluded.format, + inner_format = excluded.inner_format, + compression = excluded.compression, payload = excluded.payload, - byte_length = excluded.byte_length + stored_byte_length = excluded.stored_byte_length, + decoded_byte_length = excluded.decoded_byte_length, + decoded_blake3 = excluded.decoded_blake3 " } }; tx.prepare_cached(payload_sql)?.execute(params![ layer.id().to_string(), GLYPH_LAYER_FORMAT, - bytes, - byte_length, + stored.compression.as_str(), + stored.bytes.as_slice(), + stored_byte_length, + decoded_byte_length, + stored.decoded_blake3.as_slice(), ])?; write_component_index(tx, layer, mode) } -fn encoded_len(bytes: &[u8]) -> Result { - i64::try_from(bytes.len()).map_err(|_| StoreError::LayerPayloadTooLarge { - bytes: bytes.len() as u64, +fn encoded_len(bytes: u64) -> Result { + i64::try_from(bytes).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes, limit: MAX_LAYER_PAYLOAD_BYTES as u64, }) } @@ -654,14 +754,8 @@ fn layer_owner( } fn map_directory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let byte_length = row.get::<_, i64>(6)?; - let payload_byte_length = u64::try_from(byte_length).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - 6, - rusqlite::types::Type::Integer, - Box::new(error), - ) - })?; + let stored_byte_length = directory_byte_length(row, 6)?; + let decoded_byte_length = directory_byte_length(row, 7)?; Ok(GlyphLayerDirectoryEntry { layer_id: font::LayerId::from_raw(row.get::<_, String>(0)?), glyph_id: font::GlyphId::from_raw(row.get::<_, String>(1)?), @@ -669,7 +763,19 @@ fn map_directory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result>(3)?.map(font::GlyphName::from), width: row.get(4)?, height: row.get(5)?, - payload_byte_length, + stored_byte_length, + decoded_byte_length, + }) +} + +fn directory_byte_length(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result { + let byte_length = row.get::<_, i64>(index)?; + u64::try_from(byte_length).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Integer, + Box::new(error), + ) }) } @@ -726,15 +832,36 @@ mod tests { write_layer_in_tx(&tx, &glyph_id, Some(&glyph_name), &layer).unwrap(); tx.commit().unwrap(); - let stored: Vec = store + let stored = store .conn .query_row( - "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + "SELECT compression, payload, stored_byte_length, + decoded_byte_length, decoded_blake3 + FROM glyph_layer_payloads WHERE layer_id = ?1", [layer.id().to_string()], - |row| row.get(0), + |row| { + Ok(StoredGlyphLayer { + compression: match row.get::<_, String>(0)?.as_str() { + "zstd.v1" => crate::types::LayerPayloadCompression::ZstandardV1, + _ => crate::types::LayerPayloadCompression::None, + }, + bytes: row.get(1)?, + stored_byte_length: row.get::<_, i64>(2)? as u64, + decoded_byte_length: row.get::<_, i64>(3)? as u64, + decoded_blake3: row.get::<_, Vec>(4)?.try_into().unwrap(), + }) + }, ) .unwrap(); - assert_eq!(stored.as_slice(), golden); + assert_eq!( + stored.compression, + crate::types::LayerPayloadCompression::ZstandardV1 + ); + assert!(stored.stored_byte_length < stored.decoded_byte_length); + assert_eq!( + decompress_glyph_layer(layer.id().as_str(), stored).unwrap(), + golden + ); let decoded = store.load_glyph_layer(&layer.id()).unwrap().unwrap(); assert_eq!(font::pack_glyph_layer(&decoded).unwrap().as_bytes(), golden); } @@ -769,6 +896,104 @@ mod tests { )); } + #[test] + fn batch_bounds_declared_decoded_bytes_before_fetching_payloads() { + let (store, font) = populated_store(); + let layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .take(2) + .collect::>(); + let decoded_byte_length = MAX_LAYER_READ_BATCH_DECODED_BYTES / 2 + 1; + for layer_id in &layer_ids { + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = ?1 + WHERE layer_id = ?2", + params![decoded_byte_length as i64, layer_id.to_string()], + ) + .unwrap(); + } + + assert!(matches!( + store.load_glyph_layers(&layer_ids), + Err(StoreError::LayerReadBatchDecodedTooLarge { .. }) + )); + } + + #[test] + fn mixed_compressed_and_uncompressed_layers_load_in_stable_order() { + let (store, font) = populated_store(); + let mut layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .take(2) + .collect::>(); + layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); + let uncompressed_layer = font.layer(layer_ids[0].clone()).unwrap(); + let packed = font::pack_glyph_layer(uncompressed_layer).unwrap(); + let decoded_blake3 = blake3::hash(packed.as_bytes()); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET compression = 'none', payload = ?1, + stored_byte_length = ?2, decoded_byte_length = ?2, + decoded_blake3 = ?3 + WHERE layer_id = ?4", + params![ + packed.as_bytes(), + packed.as_bytes().len() as i64, + decoded_blake3.as_bytes().as_slice(), + layer_ids[0].to_string(), + ], + ) + .unwrap(); + + let loaded = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].id(), layer_ids[0]); + assert_eq!(loaded[1].id(), layer_ids[1]); + } + + #[test] + fn false_decoded_length_and_hash_are_rejected() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = decoded_byte_length + 1 + WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + assert!(store.load_glyph_layer(layer_id).is_err()); + + let packed = font::pack_glyph_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = ?1, decoded_blake3 = zeroblob(32) + WHERE layer_id = ?2", + params![packed.as_bytes().len() as i64, layer_id.to_string()], + ) + .unwrap(); + assert!(store.load_glyph_layer(layer_id).is_err()); + } + #[test] fn directory_open_never_reads_malformed_payloads() { let (store, font) = populated_store(); @@ -783,7 +1008,9 @@ mod tests { store .conn .execute( - "UPDATE glyph_layer_payloads SET payload = X'00', byte_length = 1 WHERE layer_id = ?1", + "UPDATE glyph_layer_payloads + SET payload = X'00', stored_byte_length = 1 + WHERE layer_id = ?1", [layer_id.to_string()], ) .unwrap(); @@ -951,7 +1178,9 @@ mod tests { store .conn .execute( - "UPDATE glyph_layer_payloads SET payload = X'00', byte_length = 1 WHERE layer_id = ?1", + "UPDATE glyph_layer_payloads + SET payload = X'00', stored_byte_length = 1 + WHERE layer_id = ?1", [layer.id().to_string()], ) .unwrap(); @@ -1029,7 +1258,12 @@ mod tests { .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, 1000, NULL)") .unwrap(); let mut payload_stmt = tx - .prepare("INSERT INTO glyph_layer_payloads (layer_id, format, payload, byte_length) VALUES (?1, ?2, X'00', 1)") + .prepare( + "INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, 'none', X'00', 1, 1, zeroblob(32))", + ) .unwrap(); for index in 0..GLYPH_COUNT { let glyph_id = font::GlyphId::from_raw(format!("cjk{index}")).to_string(); diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index a2307a53..daaac69c 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -127,9 +127,14 @@ ON glyph_layers(source_id); CREATE TABLE IF NOT EXISTS glyph_layer_payloads ( layer_id TEXT PRIMARY KEY, - format TEXT NOT NULL, + inner_format TEXT NOT NULL, + compression TEXT NOT NULL CHECK (compression IN ('none', 'zstd.v1')), payload BLOB NOT NULL, - byte_length INTEGER NOT NULL CHECK (byte_length >= 0 AND byte_length = length(payload)), + stored_byte_length INTEGER NOT NULL + CHECK (stored_byte_length >= 0 AND stored_byte_length = length(payload)), + decoded_byte_length INTEGER NOT NULL + CHECK (decoded_byte_length >= 0 AND stored_byte_length <= decoded_byte_length), + decoded_blake3 BLOB NOT NULL CHECK (length(decoded_blake3) = 32), FOREIGN KEY (layer_id) REFERENCES glyph_layers(id) ON DELETE CASCADE ); diff --git a/crates/shift-store/src/stored_layer.rs b/crates/shift-store/src/stored_layer.rs new file mode 100644 index 00000000..543b906c --- /dev/null +++ b/crates/shift-store/src/stored_layer.rs @@ -0,0 +1,204 @@ +use std::cell::RefCell; + +use shift_font as font; + +use crate::{ + StoreError, + types::{LayerPayloadCompression, StoredGlyphLayer}, +}; + +const ZSTD_COMPRESSION_LEVEL: i32 = 1; + +thread_local! { + static ZSTD_COMPRESSOR: RefCell>> = const { RefCell::new(None) }; + static ZSTD_DECOMPRESSOR: RefCell>> = const { RefCell::new(None) }; +} + +pub(crate) fn compress_glyph_layer(decoded: &[u8]) -> Result { + check_layer_length(decoded.len() as u64)?; + let compressed = ZSTD_COMPRESSOR.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some( + zstd::bulk::Compressor::new(ZSTD_COMPRESSION_LEVEL) + .map_err(|error| StoreError::LayerCompression(error.to_string()))?, + ); + } + slot.as_mut() + .expect("compressor was initialized") + .compress(decoded) + .map_err(|error| StoreError::LayerCompression(error.to_string())) + })?; + let decoded_byte_length = decoded.len() as u64; + let decoded_blake3 = *blake3::hash(decoded).as_bytes(); + + if compressed.len() < decoded.len() { + return Ok(StoredGlyphLayer { + compression: LayerPayloadCompression::ZstandardV1, + stored_byte_length: compressed.len() as u64, + decoded_byte_length, + decoded_blake3, + bytes: compressed, + }); + } + + Ok(StoredGlyphLayer { + compression: LayerPayloadCompression::None, + stored_byte_length: decoded_byte_length, + decoded_byte_length, + decoded_blake3, + bytes: decoded.to_vec(), + }) +} + +pub(crate) fn decompress_glyph_layer( + layer_id: &str, + stored: StoredGlyphLayer, +) -> Result, StoreError> { + check_layer_length(stored.stored_byte_length)?; + check_layer_length(stored.decoded_byte_length)?; + if stored.bytes.len() as u64 != stored.stored_byte_length { + return Err(directory_mismatch( + layer_id, + format!( + "stored byte length {} != actual {}", + stored.stored_byte_length, + stored.bytes.len() + ), + )); + } + + let decoded = match stored.compression { + LayerPayloadCompression::None => stored.bytes, + LayerPayloadCompression::ZstandardV1 => { + let frame_length = + zstd::zstd_safe::find_frame_compressed_size(&stored.bytes).map_err(|code| { + StoreError::LayerDecompression(zstd::zstd_safe::get_error_name(code).to_owned()) + })?; + if frame_length != stored.bytes.len() { + return Err(directory_mismatch( + layer_id, + format!( + "zstd frame has {frame_length} bytes, stored payload has {}", + stored.bytes.len() + ), + )); + } + let decoded_capacity = usize::try_from(stored.decoded_byte_length).map_err(|_| { + StoreError::LayerPayloadTooLarge { + bytes: stored.decoded_byte_length, + limit: font::MAX_LAYER_PAYLOAD_BYTES as u64, + } + })?; + ZSTD_DECOMPRESSOR.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some( + zstd::bulk::Decompressor::new() + .map_err(|error| StoreError::LayerDecompression(error.to_string()))?, + ); + } + slot.as_mut() + .expect("decompressor was initialized") + .decompress(&stored.bytes, decoded_capacity) + .map_err(|error| StoreError::LayerDecompression(error.to_string())) + })? + } + }; + + if decoded.len() as u64 != stored.decoded_byte_length { + return Err(directory_mismatch( + layer_id, + format!( + "decoded byte length {} != actual {}", + stored.decoded_byte_length, + decoded.len() + ), + )); + } + if blake3::hash(&decoded).as_bytes() != &stored.decoded_blake3 { + return Err(directory_mismatch(layer_id, "decoded BLAKE3 mismatch")); + } + Ok(decoded) +} + +pub(crate) fn parse_layer_compression(value: &str) -> Result { + match value { + "none" => Ok(LayerPayloadCompression::None), + "zstd.v1" => Ok(LayerPayloadCompression::ZstandardV1), + value => Err(StoreError::UnsupportedLayerCompression(value.to_owned())), + } +} + +fn check_layer_length(bytes: u64) -> Result<(), StoreError> { + let limit = font::MAX_LAYER_PAYLOAD_BYTES as u64; + if bytes > limit { + return Err(StoreError::LayerPayloadTooLarge { bytes, limit }); + } + Ok(()) +} + +fn directory_mismatch(layer_id: &str, detail: impl Into) -> StoreError { + StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_owned(), + detail: detail.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incompressible_bytes_fall_back_to_none() { + let mut value = 0x1234_5678_u32; + let decoded = (0..64) + .map(|_| { + value = value.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (value >> 24) as u8 + }) + .collect::>(); + + let stored = compress_glyph_layer(&decoded).unwrap(); + + assert_eq!(stored.compression, LayerPayloadCompression::None); + assert_eq!( + decompress_glyph_layer("layer_test", stored).unwrap(), + decoded + ); + } + + #[test] + fn compressed_bytes_reject_truncation_trailing_data_and_wrong_hash() { + let decoded = vec![0; 4096]; + let mut truncated = compress_glyph_layer(&decoded).unwrap(); + assert_eq!(truncated.compression, LayerPayloadCompression::ZstandardV1); + truncated.bytes.pop(); + truncated.stored_byte_length -= 1; + assert!(decompress_glyph_layer("layer_test", truncated).is_err()); + + let mut trailing = compress_glyph_layer(&decoded).unwrap(); + trailing.bytes.push(0); + trailing.stored_byte_length += 1; + assert!(decompress_glyph_layer("layer_test", trailing).is_err()); + + let mut wrong_hash = compress_glyph_layer(&decoded).unwrap(); + wrong_hash.decoded_blake3[0] ^= 1; + assert!(decompress_glyph_layer("layer_test", wrong_hash).is_err()); + } + + #[test] + fn unsupported_compression_and_oversized_decode_are_rejected() { + assert!(matches!( + parse_layer_compression("zstd.v2"), + Err(StoreError::UnsupportedLayerCompression(_)) + )); + + let mut stored = compress_glyph_layer(&vec![0; 4096]).unwrap(); + stored.decoded_byte_length = font::MAX_LAYER_PAYLOAD_BYTES as u64 + 1; + assert!(matches!( + decompress_glyph_layer("layer_test", stored), + Err(StoreError::LayerPayloadTooLarge { .. }) + )); + } +} diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index cfab7013..a0f32446 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -7,9 +7,10 @@ use shift_font as font; use crate::{ LayerBatchTiming, PackedGlyphBatch, ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, - packed_layer::store_packed_layer_in_tx, + packed_layer::store_stored_layer_in_tx, schema::{defer_import_indexes, restore_import_indexes}, - types::PackedGlyph, + stored_layer::compress_glyph_layer, + types::{PackedGlyph, StoredGlyphLayer}, write_mode::WriteMode, }; @@ -43,8 +44,11 @@ impl ShiftStore { /// The returned batch retains glyph directory facts for the single writer. pub fn pack_glyph_batch(glyphs: Vec) -> Result { let started = Instant::now(); - let layers = pack_glyph_layers(&glyphs)?; + let packed_layers = pack_glyph_layers(&glyphs)?; let pack_elapsed = started.elapsed(); + let started = Instant::now(); + let layers = compress_glyph_layers(packed_layers)?; + let compression_elapsed = started.elapsed(); let glyphs = glyphs .into_iter() .zip(layers) @@ -54,6 +58,7 @@ pub fn pack_glyph_batch(glyphs: Vec) -> Result { glyphs: &[font::Glyph], ) -> Result { let started = Instant::now(); - let layers = pack_glyph_layers(glyphs)?; + let packed_layers = pack_glyph_layers(glyphs)?; let pack_elapsed = started.elapsed(); + let started = Instant::now(); + let layers = compress_glyph_layers(packed_layers)?; + let compression_elapsed = started.elapsed(); let started = Instant::now(); for (glyph, layers) in glyphs.iter().zip(&layers) { @@ -80,6 +88,7 @@ impl LayerStreamWriter<'_> { Ok(LayerBatchTiming { pack_elapsed, + compression_elapsed, sqlite_elapsed: started.elapsed(), }) } @@ -99,10 +108,10 @@ impl LayerStreamWriter<'_> { fn write_packed_glyph( &mut self, glyph: &font::Glyph, - layers: &[(font::LayerId, font::PackedGlyphLayer)], + layers: &[(font::LayerId, StoredGlyphLayer)], ) -> Result<(), StoreError> { write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, WriteMode::Insert)?; - for (layer_id, packed) in layers { + for (layer_id, stored) in layers { let layer = glyph .layers() .get(layer_id) @@ -110,12 +119,12 @@ impl LayerStreamWriter<'_> { kind: "glyph layer", id: layer_id.to_string(), })?; - store_packed_layer_in_tx( + store_stored_layer_in_tx( &self.tx, &glyph.id(), Some(glyph.glyph_name()), layer, - packed, + stored, WriteMode::Insert, )?; } @@ -147,6 +156,22 @@ fn pack_glyph_layers( .map_err(Into::into) } +fn compress_glyph_layers( + layers: Vec>, +) -> Result>, StoreError> { + layers + .into_par_iter() + .map(|layers| { + layers + .into_iter() + .map(|(layer_id, packed)| { + compress_glyph_layer(packed.as_bytes()).map(|stored| (layer_id, stored)) + }) + .collect::, _>>() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index 9e95619c..488d4d61 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -1,27 +1,56 @@ use std::time::Duration; -use shift_font::{Glyph, LayerId, PackedGlyphLayer}; +use shift_font::{Glyph, LayerId}; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct LayerBatchTiming { pub pack_elapsed: Duration, + pub compression_elapsed: Duration, pub sqlite_elapsed: Duration, } pub struct PackedGlyphBatch { pub(crate) glyphs: Vec, pub(crate) pack_elapsed: Duration, + pub(crate) compression_elapsed: Duration, } impl PackedGlyphBatch { pub fn pack_elapsed(&self) -> Duration { self.pack_elapsed } + + pub fn compression_elapsed(&self) -> Duration { + self.compression_elapsed + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LayerPayloadCompression { + None, + ZstandardV1, +} + +impl LayerPayloadCompression { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::ZstandardV1 => "zstd.v1", + } + } +} + +pub(crate) struct StoredGlyphLayer { + pub(crate) compression: LayerPayloadCompression, + pub(crate) bytes: Vec, + pub(crate) stored_byte_length: u64, + pub(crate) decoded_byte_length: u64, + pub(crate) decoded_blake3: [u8; 32], } pub(crate) struct PackedGlyph { pub(crate) glyph: Glyph, - pub(crate) layers: Vec<(LayerId, PackedGlyphLayer)>, + pub(crate) layers: Vec<(LayerId, StoredGlyphLayer)>, } #[derive(Clone, Debug, Eq, PartialEq, Hash)] diff --git a/crates/shift-workspace/examples/profile_streaming_import.rs b/crates/shift-workspace/examples/profile_streaming_import.rs index 1a858423..7a30916c 100644 --- a/crates/shift-workspace/examples/profile_streaming_import.rs +++ b/crates/shift-workspace/examples/profile_streaming_import.rs @@ -57,6 +57,7 @@ fn main() -> Result<(), Box> { let pipeline_started = Instant::now(); let mut parse = Duration::ZERO; let mut pack = Duration::ZERO; + let mut compression = Duration::ZERO; let mut sqlite = Duration::ZERO; let mut glyph_count = 0; let mut layer_count = 0; @@ -64,6 +65,7 @@ fn main() -> Result<(), Box> { stream_into(import, &mut writer, batch_limit, |batch| { parse += batch.parse_elapsed; pack += batch.pack_elapsed; + compression += batch.compression_elapsed; sqlite += batch.sqlite_elapsed; glyph_count += batch.glyph_count; layer_count += batch.layer_count; @@ -72,9 +74,10 @@ fn main() -> Result<(), Box> { if batches % progress_batches == 0 { let wall = pipeline_started.elapsed(); println!( - "progress batches={batches} glyphs={glyph_count} layers={layer_count} parse_ms={:.3} pack_ms={:.3} sqlite_ms={:.3} wall_ms={:.3} glyphs_per_sec={:.1}", + "progress batches={batches} glyphs={glyph_count} layers={layer_count} parse_ms={:.3} pack_ms={:.3} compression_ms={:.3} sqlite_ms={:.3} wall_ms={:.3} glyphs_per_sec={:.1}", ms(parse), ms(pack), + ms(compression), ms(sqlite), ms(wall), glyph_count as f64 / wall.as_secs_f64() @@ -83,6 +86,7 @@ fn main() -> Result<(), Box> { })?; println!("parse_ms={:.3}", ms(parse)); println!("pack_ms={:.3}", ms(pack)); + println!("compression_ms={:.3}", ms(compression)); println!("sqlite_ms={:.3}", ms(sqlite)); println!("pipeline_wall_ms={:.3}", ms(pipeline_started.elapsed())); println!("batch_count={batches}"); @@ -110,7 +114,14 @@ fn main() -> Result<(), Box> { "packed_bytes={}", layer_directory .iter() - .map(|entry| entry.payload_byte_length) + .map(|entry| entry.decoded_byte_length) + .sum::() + ); + println!( + "stored_payload_bytes={}", + layer_directory + .iter() + .map(|entry| entry.stored_byte_length) .sum::() ); println!("inspection_ms={:.3}", ms(started.elapsed())); diff --git a/crates/shift-workspace/src/import_pipeline.rs b/crates/shift-workspace/src/import_pipeline.rs index a3d180d8..fd3812cb 100644 --- a/crates/shift-workspace/src/import_pipeline.rs +++ b/crates/shift-workspace/src/import_pipeline.rs @@ -12,6 +12,7 @@ pub struct ImportBatchProgress { pub layer_count: usize, pub parse_elapsed: Duration, pub pack_elapsed: Duration, + pub compression_elapsed: Duration, pub sqlite_elapsed: Duration, } @@ -97,12 +98,14 @@ pub fn stream_into( for result in packed_receiver { let batch = result?; let pack_elapsed = batch.glyphs.pack_elapsed(); + let compression_elapsed = batch.glyphs.compression_elapsed(); let sqlite_elapsed = writer.write_packed_glyph_batch(batch.glyphs)?; observe(ImportBatchProgress { glyph_count: batch.glyph_count, layer_count: batch.layer_count, parse_elapsed: batch.parse_elapsed, pack_elapsed, + compression_elapsed, sqlite_elapsed, }); } diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 75662bcc..09cd622d 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -356,6 +356,57 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { } } +#[test] +fn ufo_source_roundtrips_through_lazy_acquisition() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); + let store_path = temp.path().join("mutatorsans-ufo.sqlite"); + + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let glyph_id = workspace + .font() + .glyphs() + .find(|glyph| !glyph.layers().is_empty()) + .expect("fixture should contain a glyph layer") + .id(); + assert_eq!(workspace.loaded_layer_count(), 0); + + workspace + .acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs) + .unwrap(); + let expected = workspace + .font() + .glyph(glyph_id.clone()) + .unwrap() + .layers() + .values() + .map(|layer| { + ( + layer.id(), + shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), + ) + }) + .collect::>(); + assert!(!expected.is_empty()); + drop(workspace); + + let mut resumed = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(resumed.loaded_layer_count(), 0); + resumed + .acquire_glyphs(std::slice::from_ref(&glyph_id), AcquireScope::Glyphs) + .unwrap(); + + assert_eq!(resumed.loaded_layer_count(), expected.len()); + for (layer_id, bytes) in expected { + assert_eq!( + shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) + .unwrap() + .as_bytes(), + bytes + ); + } +} + #[test] fn save_requires_save_as_for_untitled_workspaces() { let temp = tempfile::tempdir().unwrap(); diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 1402ff24..ecd074ce 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -20,7 +20,7 @@ Central routing table for Shift's distributed documentation. Before creating new | `crates/shift-glyph-codec/**` | [`crates/shift-glyph-codec/SPECIFICATION.md`](../../crates/shift-glyph-codec/SPECIFICATION.md) | Packed glyph framing, validation, and compatibility | | `crates/shift-slug/**` | [`crates/shift-slug/docs/DOCS.md`](../../crates/shift-slug/docs/DOCS.md) | GPU-independent Slug curves, bands, and atlas packing | | `crates/shift-source/**` | [`crates/shift-source/docs/DOCS.md`](../../crates/shift-source/docs/DOCS.md) | User-authored `.shift` source package layout | -| `crates/shift-store/**` | [`crates/shift-store/README.md`](../../crates/shift-store/README.md) | SQLite directory, packed layer payloads, and reference indexes | +| `crates/shift-store/**` | [`crates/shift-store/README.md`](../../crates/shift-store/README.md) | SQLite directory, independently compressed canonical layer payloads, and reference indexes | | `crates/shift-workspace/**` | [`crates/shift-workspace/docs/DOCS.md`](../../crates/shift-workspace/docs/DOCS.md) | Open font workspace runtime over source, store, and font | | `crates/shift-bridge/**` | [`crates/shift-bridge/docs/DOCS.md`](../../crates/shift-bridge/docs/DOCS.md) | NAPI bridge exposing Rust to Node.js/Electron | From ffcb2f436377cdb09124d53525c6a6d476ee2d2a Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 07:34:22 +0000 Subject: [PATCH 18/29] Avoid full font copies during layer hydration --- crates/shift-font/docs/DOCS.md | 2 +- crates/shift-font/src/ir/font.rs | 163 ++++++++++++++++++++++-- crates/shift-workspace/docs/DOCS.md | 10 +- crates/shift-workspace/src/workspace.rs | 15 +-- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index e4d1c10c..3385e6b8 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -70,7 +70,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. - Own font authoring data structures such as `Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, `Source`, and `Axis`. - Keep object-level mutation behavior near the objects it mutates. -- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` atomically hydrates or evicts a bounded layer batch with one copy-on-write index rebuild. +- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` validates a complete hydration/eviction batch before mutation, moves its validated identity sets into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. - Own canonical glyph and source-metric interpolation value ordering, variation-model construction, interpolation evaluation, and location-bound glyph resolution. - Stay independent of TypeScript, NAPI, and bridge DTOs. diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index a611def3..47a7cb01 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -275,6 +275,86 @@ impl FontIndex { Ok(()) } + fn validate_layer_replacements( + &self, + replacements: &[(GlyphId, GlyphLayer, GlyphLayer)], + replacement_contour_ids: &mut HashSet, + replacement_point_ids: &mut HashSet, + replacement_component_ids: &mut HashSet, + replacement_anchor_ids: &mut HashSet, + replacement_guideline_ids: &mut HashSet, + ) -> CoreResult<()> { + let mut removed_contour_ids = HashSet::new(); + let mut removed_point_ids = HashSet::new(); + let mut removed_component_ids = HashSet::new(); + let mut removed_anchor_ids = HashSet::new(); + let mut removed_guideline_ids = HashSet::new(); + for (_, previous, _) in replacements { + for contour in previous.contours_iter() { + removed_contour_ids.insert(contour.id()); + removed_point_ids.extend(contour.points().iter().map(|point| point.id())); + } + removed_component_ids + .extend(previous.components_iter().map(|component| component.id())); + removed_anchor_ids.extend(previous.anchors_iter().map(|anchor| anchor.id())); + removed_guideline_ids.extend(previous.guidelines().iter().map(Guideline::id)); + } + + for (_, _, replacement) in replacements { + for contour in replacement.contours_iter() { + let contour_id = contour.id(); + if (self.contour_ids.contains(&contour_id) + && !removed_contour_ids.contains(&contour_id)) + || !replacement_contour_ids.insert(contour_id.clone()) + { + return Err(CoreError::DuplicateContourId(contour_id)); + } + + for point in contour.points() { + let point_id = point.id(); + if (self.point_ids.contains(&point_id) + && !removed_point_ids.contains(&point_id)) + || !replacement_point_ids.insert(point_id.clone()) + { + return Err(CoreError::DuplicatePointId(point_id)); + } + } + } + + for component in replacement.components_iter() { + let component_id = component.id(); + if (self.component_ids.contains(&component_id) + && !removed_component_ids.contains(&component_id)) + || !replacement_component_ids.insert(component_id.clone()) + { + return Err(CoreError::DuplicateComponentId(component_id)); + } + } + + for anchor in replacement.anchors_iter() { + let anchor_id = anchor.id(); + if (self.anchor_ids.contains(&anchor_id) + && !removed_anchor_ids.contains(&anchor_id)) + || !replacement_anchor_ids.insert(anchor_id.clone()) + { + return Err(CoreError::DuplicateAnchorId(anchor_id)); + } + } + + for guideline in replacement.guidelines() { + let guideline_id = guideline.id(); + if (self.guideline_ids.contains(&guideline_id) + && !removed_guideline_ids.contains(&guideline_id)) + || !replacement_guideline_ids.insert(guideline_id.clone()) + { + return Err(CoreError::DuplicateGuidelineId(guideline_id)); + } + } + } + + Ok(()) + } + fn insert_layer(&mut self, glyph_id: GlyphId, layer: &GlyphLayer) { self.layer_owner.insert(layer.id(), glyph_id.clone()); self.layer_by_glyph_source @@ -1106,8 +1186,8 @@ impl Font { Ok(()) } - /// Atomically replaces existing layers while cloning and validating the - /// font index only once for the complete batch. + /// Atomically replaces existing layers after validating the complete batch. + /// Uniquely owned fonts mutate in place; shared snapshots retain copy-on-write semantics. pub fn replace_glyph_layers(&mut self, layers: Vec) -> CoreResult<()> { if layers.is_empty() { return Ok(()); @@ -1138,22 +1218,43 @@ impl Font { replacements.push((glyph_id, previous, layer)); } - let mut next_state = self.state.as_ref().clone(); + let mut replacement_contour_ids = HashSet::new(); + let mut replacement_point_ids = HashSet::new(); + let mut replacement_component_ids = HashSet::new(); + let mut replacement_anchor_ids = HashSet::new(); + let mut replacement_guideline_ids = HashSet::new(); + self.index().validate_layer_replacements( + &replacements, + &mut replacement_contour_ids, + &mut replacement_point_ids, + &mut replacement_component_ids, + &mut replacement_anchor_ids, + &mut replacement_guideline_ids, + )?; + + let state = self.state_mut(); for (glyph_id, previous, _) in &replacements { - next_state.index.remove_layer(glyph_id.clone(), previous); + state.index.remove_layer(glyph_id.clone(), previous); } for (glyph_id, _, layer) in replacements { - next_state.index.validate_layer_insert(&glyph_id, &layer)?; - next_state.index.insert_layer(glyph_id.clone(), &layer); - let glyph = next_state + state.index.layer_owner.insert(layer.id(), glyph_id.clone()); + state + .index + .layer_by_glyph_source + .insert((glyph_id.clone(), layer.source_id()), layer.id()); + let glyph = state .data .glyphs .get_mut(&glyph_id) - .ok_or(CoreError::GlyphNotFound(glyph_id))?; + .expect("replacement owner was resolved before mutation"); Arc::make_mut(glyph).set_layer(layer); } + state.index.contour_ids.extend(replacement_contour_ids); + state.index.point_ids.extend(replacement_point_ids); + state.index.component_ids.extend(replacement_component_ids); + state.index.anchor_ids.extend(replacement_anchor_ids); + state.index.guideline_ids.extend(replacement_guideline_ids); - self.state = Arc::new(next_state); Ok(()) } @@ -1759,6 +1860,50 @@ mod tests { assert_eq!(font.layer(second_layer_id).unwrap().width(), 800.0); } + #[test] + fn glyph_layer_batch_replacement_preserves_snapshots_and_identity_indexes() { + let mut font = Font::new(); + let source_id = font.default_source_id().unwrap(); + let contour_id = ContourId::new(); + let mut contour = Contour::with_id(contour_id.clone()); + contour.add_point(0.0, 0.0, PointType::OnCurve, false); + let point_id = contour.points()[0].id(); + let mut first_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 500.0); + first_layer.add_contour(contour); + let first_layer_id = first_layer.id(); + let mut first_glyph = Glyph::new("A"); + first_glyph.set_layer(first_layer); + font.insert_glyph(first_glyph).unwrap(); + + let second_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 600.0); + let second_layer_id = second_layer.id(); + let mut second_glyph = Glyph::new("B"); + second_glyph.set_layer(second_layer); + font.insert_glyph(second_glyph).unwrap(); + + let snapshot = font.clone(); + let mut replacement = font.layer(first_layer_id.clone()).unwrap().clone(); + replacement.set_width(700.0); + font.replace_glyph_layers(vec![replacement]).unwrap(); + + assert_eq!(font.layer(first_layer_id.clone()).unwrap().width(), 700.0); + assert_eq!(snapshot.layer(first_layer_id).unwrap().width(), 500.0); + assert!(font.has_contour_id(&contour_id)); + assert!(font.has_point_id(&point_id)); + + let mut conflicting_contour = Contour::with_id(contour_id.clone()); + conflicting_contour.add_point(10.0, 10.0, PointType::OnCurve, false); + let mut conflicting_layer = + GlyphLayer::with_width(second_layer_id.clone(), source_id, 800.0); + conflicting_layer.add_contour(conflicting_contour); + + assert!(matches!( + font.replace_glyph_layers(vec![conflicting_layer]), + Err(CoreError::DuplicateContourId(id)) if id == contour_id + )); + assert_eq!(font.layer(second_layer_id).unwrap().width(), 600.0); + } + #[test] fn font_remove_insert_glyph() { let mut font = Font::new(); diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index bf866446..71f935c0 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -6,7 +6,7 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** `FontWorkspace` composes a directory-complete, payload-lazy `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. - **Architecture Invariant:** Resuming SQLite loads metadata and glyph/layer directory facts only. `acquire_glyphs` performs explicit bounded payload I/O; synchronous `font()` reads never initiate I/O. -- **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `LayerStreamWriter`; they never construct a complete geometry-resident `Font`. Format readers and canonical packing use Rayon, while SQLite has one transaction owner. +- **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `LayerStreamWriter`; they never construct a complete geometry-resident `Font`. Format readers, canonical packing, BLAKE3 hashing, and independent per-layer compression use Rayon, while SQLite has one transaction owner. - **Architecture Invariant:** `LayerResidency` is the sole owner of loaded-layer membership and placeholder replacement. A loaded layer is only a cache of already-committed authored state; apply, undo, and redo reacquire their complete layer read sets before mutation, and `evict_glyphs` replaces only committed layers with directory placeholders. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. @@ -44,7 +44,7 @@ crates/shift-workspace/examples/ `FontWorkspace::create(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`. -`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. TTF/OTF, UFO, and Designspace paths use a metadata/directory-first backend cursor, parse batches of at most 512 glyphs and 1,024 authored layers, parallel-pack those layers, and insert them into a disposable import connection. After the complete stream commits, `finish_import` syncs the database and restores edit-time WAL settings. The returned workspace contains directory placeholders and zero loaded layer payloads. This synchronous API still returns only after finalization; publishing the directory and binary packed-outline grid while import continues requires the separate app import-session boundary. Other supported foreign formats retain the eager compatibility path until they gain a bounded reader. +`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. TTF/OTF, UFO, and Designspace paths use a metadata/directory-first backend cursor, parse batches of at most 512 glyphs and 1,024 authored layers, parallel-pack/hash/compress those layers, and insert them into a disposable import connection. After the complete stream commits, `finish_import` syncs the database and restores edit-time WAL settings. The returned workspace contains directory placeholders and zero loaded layer payloads. This synchronous API still returns only after finalization; publishing the directory and binary packed-outline grid while import continues requires the separate app import-session boundary. Other supported foreign formats retain the eager compatibility path until they gain a bounded reader. `FontWorkspace::save()` succeeds for saved `.shift` workspaces and returns `NeedsSaveAs` for imported workspaces. `save_as(path)` creates a `.shift` package and makes it the save target. @@ -52,11 +52,11 @@ crates/shift-workspace/examples/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. -`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of packed bytes, accumulates the decoded results, and installs them with one COW font-index rebuild. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of decoded bytes, decompresses and verifies exact lengths plus BLAKE3 in parallel, accumulates the canonical results, and validates the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. ## Profiling -`profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, canonical pack, SQLite write, commit, durable-finalization, native-directory materialization, reopen, BLOB, and database measurements without putting machine-specific timing assertions in tests: +`profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, canonical pack, compression, SQLite write, commit, durable-finalization, native-directory materialization, reopen, decoded/stored BLOB, and database measurements without putting machine-specific timing assertions in tests: ```bash cargo build --release -p shift-workspace --example profile_streaming_import @@ -64,7 +64,7 @@ cargo build --release -p shift-workspace --example profile_streaming_import /path/to/font-or-project /tmp/import.sqlite ``` -Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse, pack, SQLite, wall time, and throughput. Canonical packing uses Rayon; the SQLite writer remains single-threaded. +Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse, pack, compression, SQLite, wall time, and throughput. Canonical packing, hashing, and compression use Rayon; the SQLite writer remains single-threaded. A repeatable ignored corpus gate exercises streaming import, directory-only resume, and bounded acquisition without checking large fonts into Git: diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index ab1fbb40..aaa7a2cc 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -836,16 +836,14 @@ impl FontWorkspace { return Ok(()); } - // Decode through bounded SQLite reads, then clone and validate the font - // index once so any malformed batch leaves the live cache unchanged. + // Decode through bounded SQLite reads. Font validates the complete + // replacement batch before mutating, so malformed input leaves the + // live cache unchanged without copying the complete directory. let mut layers = Vec::with_capacity(layer_ids.len()); for layer_ids in layer_ids.chunks(MAX_LAYER_READ_BATCH_COUNT) { layers.extend(self.store.load_glyph_layers(layer_ids)?); } - let mut next_font = self.font.clone(); - next_font.replace_glyph_layers(layers)?; - - self.font = next_font; + self.font.replace_glyph_layers(layers)?; self.residency.mark_loaded(layer_ids); Ok(()) } @@ -876,10 +874,7 @@ impl FontWorkspace { } } - let mut next_font = self.font.clone(); - next_font.replace_glyph_layers(placeholders)?; - - self.font = next_font; + self.font.replace_glyph_layers(placeholders)?; self.residency.mark_unloaded(evicted); Ok(()) } From 01dfd65e6db76827c858a71f52fe1c750311c360 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 09:49:23 +0000 Subject: [PATCH 19/29] Preserve Designspace metrics and source order --- crates/shift-backends/docs/DOCS.md | 4 +- .../shift-backends/src/designspace/error.rs | 3 - .../shift-backends/src/designspace/import.rs | 132 ++++++++++++------ crates/shift-backends/src/ufo/import.rs | 3 +- crates/shift-backends/src/ufo/reader.rs | 33 ++--- crates/shift-backends/tests/loading.rs | 96 ++++++++++++- 6 files changed, 195 insertions(+), 76 deletions(-) diff --git a/crates/shift-backends/docs/DOCS.md b/crates/shift-backends/docs/DOCS.md index 088fe820..3d5ff54a 100644 --- a/crates/shift-backends/docs/DOCS.md +++ b/crates/shift-backends/docs/DOCS.md @@ -28,7 +28,7 @@ Font format backends that convert between on-disk font files and the `Font` IR u **Architecture Invariant:** TrueType quadratic segments remain one `OffCurve` control plus one `QCurve` endpoint in the authored layer. Closing qcurve endpoints transfer their type to the wrapped start point. CFF cubic segments remain cubic. The bridge may project a qcurve endpoint as on-curve because clients infer the quadratic from its single control; canonical storage and source export retain the distinction. WHY: lifting every TrueType quadratic to cubic adds a point and derived coordinates to every segment, inflating canonical documents without adding information. -**Architecture Invariant:** Designspace source locations are imported as complete design-space locations. An omitted source dimension resolves to that axis's user-space default mapped into design space; default-source selection compares against that same completed mapped location and never silently substitutes the first source. A `layer` attribute only selects where a source's outlines live and does not make it ineligible to be the default. WHY: these are the reference designspaceLib semantics, and mixing user defaults with design coordinates corrupts interpolation bases. +**Architecture Invariant:** Designspace source locations are imported as complete design-space locations. An omitted source dimension resolves to that axis's user-space default mapped into design space; default-source selection compares against that same completed mapped location and never silently substitutes the first source. A `layer` attribute only selects where a source's outlines live and does not make it ineligible to be the default. Each source's standard metrics are translated from that UFO's metric identities into the Designspace header definitions. WHY: mixing user defaults with design coordinates corrupts interpolation bases, while looking up one UFO's metrics with another UFO's random IDs silently drops non-default master metrics. ## Codemap @@ -80,7 +80,7 @@ src/ **Point type mapping (read):** norad uses separate `Move`, `Line`, `Curve`, `OffCurve`, `QCurve` types. The IR collapses `Move`/`Line`/`Curve` into `OnCurve` and keeps `OffCurve` and `QCurve` distinct. On write, context (position in contour, open/closed, preceding point type) is used to reconstruct the correct norad variant. -**Multi-layer support:** `UfoReader` iterates all norad layers. The `public.default` layer maps to the IR's default layer; other layers are added via `Font::add_layer`. Glyphs in non-default layers are merged into existing `Glyph` entries when the glyph already exists from another layer. +**Multi-layer support:** `UfoReader` publishes `public.default` first, then preserves the relative authored order of every other entry in `layercontents.plist`. The default layer maps to the IR's default layer; other layers are represented by layer sources. Glyphs in non-default layers are merged into existing `Glyph` entries when the glyph already exists from another layer. **Binary variation metadata:** The TTF/OTF reader imports `fvar` axis definitions, hidden flags, and named instances into the Shift IR. The bounded path enumerates every `maxp` glyph ID, groups all `cmap` values by glyph, deterministically derives contour/point identities from emitted positions, and preserves TrueType quadratics instead of expanding them to cubic control pairs. Binary glyph geometry is still materialized only at the default variation location; recovering editable `gvar` sources is separate work. diff --git a/crates/shift-backends/src/designspace/error.rs b/crates/shift-backends/src/designspace/error.rs index a477946a..c69a85a9 100644 --- a/crates/shift-backends/src/designspace/error.rs +++ b/crates/shift-backends/src/designspace/error.rs @@ -21,9 +21,6 @@ pub enum DesignspaceError { #[error("designspace has no source at the mapped default location")] MissingDefaultSource, - #[error("layer '{layer}' not found in '{filename}'")] - MissingLayer { layer: String, filename: String }, - #[error("failed to read '{path}': {source}")] ReadFile { path: PathBuf, diff --git a/crates/shift-backends/src/designspace/import.rs b/crates/shift-backends/src/designspace/import.rs index 3df17961..d3a2bfb0 100644 --- a/crates/shift-backends/src/designspace/import.rs +++ b/crates/shift-backends/src/designspace/import.rs @@ -6,7 +6,9 @@ use std::{ use norad::designspace::DesignSpaceDocument; use rayon::prelude::*; -use shift_font::{Axis, AxisId, Font, GlyphId, LayerId, Location, Source, SourceId}; +use shift_font::{ + Axis, AxisId, Font, GlyphId, LayerId, Location, MetricDefinition, Source, SourceId, +}; use crate::{ errors::{FormatBackendError, FormatBackendResult}, @@ -30,6 +32,14 @@ struct SourceDirectory { glyphs: BTreeMap, } +struct DesignspaceSourceMaterial { + index: usize, + style_name: Option, + metric_definitions: Vec, + metrics: Source, + glyphs: BTreeMap, +} + pub(crate) fn stream_font(path: &str) -> FormatBackendResult<(Font, GlifGlyphStream)> { stream_designspace(Path::new(path)).map_err(FormatBackendError::from) } @@ -73,6 +83,7 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG .default_source() .cloned() .ok_or(DesignspaceError::NoSources)?; + let default_metric_definitions = header.metric_definitions().to_vec(); header.clear_sources(); if let Some(family_name) = &default_descriptor.familyname { @@ -91,8 +102,12 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG .map(|index| { let descriptor = &document.sources[index]; let ufo_path = designspace_dir.join(&descriptor.filename); - let (style_name, imported_metrics) = if index == default_index { - (default_style_name.clone(), default_metrics.clone()) + let (style_name, metric_definitions, metrics) = if index == default_index { + ( + default_style_name.clone(), + default_metric_definitions.clone(), + default_metrics.clone(), + ) } else { let ufo_header = load_header(&ufo_path).map_err(|error| DesignspaceError::LoadUfo { @@ -103,7 +118,11 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG .default_source() .cloned() .ok_or(DesignspaceError::NoSources)?; - (ufo_header.metadata().style_name.clone(), metrics) + ( + ufo_header.metadata().style_name.clone(), + ufo_header.metric_definitions().to_vec(), + metrics, + ) }; let glyphs = read_glyph_paths(&ufo_path, descriptor.layer.as_deref()).map_err(|error| { @@ -112,32 +131,30 @@ fn stream_designspace(designspace_path: &Path) -> DesignspaceResult<(Font, GlifG source: Box::new(error), } })?; - Ok((index, style_name, imported_metrics, glyphs)) + Ok(DesignspaceSourceMaterial { + index, + style_name, + metric_definitions, + metrics, + glyphs, + }) }) .collect::>>()?; - let mut source_names = HashSet::new(); - let mut directories = Vec::with_capacity(document.sources.len()); - for (index, style_name, imported_metrics, glyphs) in source_materials { - let descriptor = &document.sources[index]; - let name = source_name(descriptor, style_name.as_deref(), &source_names, index); - source_names.insert(name.clone()); - let location = location_from_dimensions(&descriptor.location, &document, header.axes()); - let mut source = Source::with_filename(name, location, descriptor.filename.clone()); - source.set_layer_name(descriptor.layer.clone()); - let metric_definitions = header.metric_definitions().to_vec(); - copy_source_metrics( - &metric_definitions, - &imported_metrics, - &metric_definitions, - &mut source, - ); - let source_id = header.add_source(source); - if index == default_index { - header.set_default_source_id(source_id.clone()); - } - directories.push(SourceDirectory { source_id, glyphs }); - } + let axes = header.axes().to_vec(); + let directories = register_designspace_sources( + &mut header, + source_materials, + default_index, + |index, style_name, source_names| { + let descriptor = &document.sources[index]; + let name = source_name(descriptor, style_name, source_names, index); + let location = location_from_dimensions(&descriptor.location, &document, &axes); + let mut source = Source::with_filename(name, location, descriptor.filename.clone()); + source.set_layer_name(descriptor.layer.clone()); + source + }, + ); let (glyph_ids, glyphs) = build_glyph_directory(&directories); Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) @@ -211,39 +228,66 @@ fn stream_axisless_designspace( source: Box::new(error), } })?; - Ok(( + Ok(DesignspaceSourceMaterial { index, style_name, metric_definitions, - imported_metrics, + metrics: imported_metrics, glyphs, - )) + }) }) .collect::>>()?; + let directories = register_designspace_sources( + &mut header, + source_materials, + 0, + |index, style_name, source_names| { + let descriptor = &sources[index]; + let name = axisless_source_name(descriptor, style_name, source_names, index); + let mut source = + Source::with_filename(name, Location::new(), descriptor.filename.clone()); + source.set_layer_name(descriptor.layer.clone()); + source + }, + ); + + let (glyph_ids, glyphs) = build_glyph_directory(&directories); + Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) +} + +fn register_designspace_sources( + header: &mut Font, + source_materials: Vec, + default_index: usize, + mut create_source: impl FnMut(usize, Option<&str>, &HashSet) -> Source, +) -> Vec { + let metric_definitions = header.metric_definitions().to_vec(); let mut source_names = HashSet::new(); - let mut directories = Vec::with_capacity(sources.len()); - for (index, style_name, metric_definitions, imported_metrics, glyphs) in source_materials { - let descriptor = &sources[index]; - let name = axisless_source_name(descriptor, style_name.as_deref(), &source_names, index); - source_names.insert(name.clone()); - let mut source = Source::with_filename(name, Location::new(), descriptor.filename.clone()); - source.set_layer_name(descriptor.layer.clone()); + let mut directories = Vec::with_capacity(source_materials.len()); + for material in source_materials { + let mut source = create_source( + material.index, + material.style_name.as_deref(), + &source_names, + ); + source_names.insert(source.name().to_string()); copy_source_metrics( + &material.metric_definitions, + &material.metrics, &metric_definitions, - &imported_metrics, - header.metric_definitions(), &mut source, ); let source_id = header.add_source(source); - if index == 0 { + if material.index == default_index { header.set_default_source_id(source_id.clone()); } - directories.push(SourceDirectory { source_id, glyphs }); + directories.push(SourceDirectory { + source_id, + glyphs: material.glyphs, + }); } - - let (glyph_ids, glyphs) = build_glyph_directory(&directories); - Ok((header, GlifGlyphStream::new(glyph_ids, glyphs))) + directories } fn add_axes( diff --git a/crates/shift-backends/src/ufo/import.rs b/crates/shift-backends/src/ufo/import.rs index 46ed34a9..f155b022 100644 --- a/crates/shift-backends/src/ufo/import.rs +++ b/crates/shift-backends/src/ufo/import.rs @@ -40,7 +40,8 @@ fn load_layer_directories( .iter() .position(|(_, path)| path == Path::new("glyphs")) .ok_or_else(|| FormatBackendError::Ufo("UFO is missing its default glyphs layer".into()))?; - layer_paths.rotate_left(default_index); + let default_layer = layer_paths.remove(default_index); + layer_paths.insert(0, default_layer); let mut layers = Vec::with_capacity(layer_paths.len()); for (index, (name, relative_path)) in layer_paths.into_iter().enumerate() { diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index b66515a4..35864151 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -4,9 +4,9 @@ use crate::metrics::set_metric_position; use crate::traits::FontReader; use norad::{Font as NoradFont, Line}; use shift_font::{ - Anchor, Component, Contour, FeatureData, Font, Glyph, GlyphId, GlyphLayer, Guideline, - KerningData, KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, - SourceId, Transform, + Anchor, Component, Contour, FeatureData, Font, GlyphId, GlyphLayer, Guideline, KerningData, + KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, SourceId, + Transform, }; use std::collections::HashMap; use std::path::Path; @@ -184,11 +184,11 @@ impl UfoReader { LibData::from_map(data) } - fn convert_glyph_layer( + fn convert_layer_geometry( norad_glyph: &norad::Glyph, layer_id: LayerId, source_id: SourceId, - ) -> (Glyph, Vec) { + ) -> (GlyphLayer, Vec) { let mut glyph_layer = GlyphLayer::with_width(layer_id, source_id, norad_glyph.width); let mut pending_components = Vec::new(); if norad_glyph.height != 0.0 { @@ -215,17 +215,7 @@ impl UfoReader { *glyph_layer.lib_mut() = Self::convert_lib(&norad_glyph.lib); } - let mut glyph = Glyph::new(norad_glyph.name().to_string()); - for codepoint in norad_glyph.codepoints.iter() { - glyph.add_unicode(u32::from(codepoint)); - } - - if !norad_glyph.lib.is_empty() { - *glyph.lib_mut() = Self::convert_lib(&norad_glyph.lib); - } - - glyph.set_layer(glyph_layer); - (glyph, pending_components) + (glyph_layer, pending_components) } pub(crate) fn convert_stream_layer( @@ -234,15 +224,8 @@ impl UfoReader { source_id: SourceId, glyph_ids: &HashMap, ) -> FormatBackendResult { - let (glyph, pending_components) = - Self::convert_glyph_layer(norad_glyph, layer_id, source_id); - let mut layer = glyph - .layers() - .values() - .next() - .ok_or_else(|| FormatBackendError::Ufo("converted glyph is missing its layer".into()))? - .as_ref() - .clone(); + let (mut layer, pending_components) = + Self::convert_layer_geometry(norad_glyph, layer_id, source_id); for pending in pending_components { let base_glyph_id = glyph_ids.get(&pending.base_glyph_name).ok_or_else(|| { FormatBackendError::Ufo(format!( diff --git a/crates/shift-backends/tests/loading.rs b/crates/shift-backends/tests/loading.rs index eb223a4c..58240c71 100644 --- a/crates/shift-backends/tests/loading.rs +++ b/crates/shift-backends/tests/loading.rs @@ -1,4 +1,7 @@ -use std::path::{Path, PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use shift_backends::font_loader::FontLoader; use shift_font::{Contour, Font, Glyph, GlyphLayer, LayerId, MetricKind, PointType}; @@ -12,6 +15,19 @@ fn fixtures_path() -> PathBuf { .join("fixtures") } +fn copy_directory(source: &Path, destination: &Path) { + fs::create_dir_all(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let destination_path = destination.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_directory(&entry.path(), &destination_path); + } else { + fs::copy(entry.path(), destination_path).unwrap(); + } + } +} + fn mutatorsans_ufo_path() -> PathBuf { fixtures_path().join("fonts/mutatorsans/MutatorSansLightCondensed.ufo") } @@ -235,6 +251,70 @@ fn streams_binary_ufo_and_designspace_without_eager_glyphs() { ); } +#[test] +fn ufo_import_keeps_nondefault_layer_order_when_default_was_not_first() { + let temp = tempfile::tempdir().unwrap(); + let ufo_path = temp.path().join("Reordered.ufo"); + copy_directory(&mutatorsans_ufo_path(), &ufo_path); + let layercontents_path = ufo_path.join("layercontents.plist"); + let mut layercontents = plist::Value::from_file(&layercontents_path).unwrap(); + let layers = layercontents.as_array_mut().unwrap(); + let default = layers.remove(0); + layers.insert(2, default); + layercontents.to_file_xml(&layercontents_path).unwrap(); + + let font = load_font(&ufo_path); + let source_names = font + .sources() + .iter() + .map(|source| source.name().to_string()) + .collect::>(); + + assert_eq!( + &source_names[..3], + &["Regular", "support", "support.crossbar"] + ); +} + +#[test] +fn streaming_batches_preserve_published_directory_order() { + for path in [ + mutatorsans_ttf_path(), + mutatorsans_ufo_path(), + mutatorsans_designspace_path(), + ] { + let mut import = FontLoader::new() + .stream_font(path.to_str().unwrap()) + .unwrap(); + let expected = import + .directory() + .into_iter() + .map(|entry| (entry.glyph_id, entry.name)) + .collect::>(); + let mut actual = Vec::new(); + loop { + let batch = import + .next_batch(shift_backends::ImportBatchLimit::new(7, 20)) + .unwrap(); + if batch.is_empty() { + break; + } + actual.extend( + batch + .into_iter() + .map(|glyph| (glyph.id(), glyph.glyph_name().clone())), + ); + } + + assert_eq!( + actual, + expected, + "stream order changed for {}", + path.display() + ); + } +} + #[test] fn streaming_batches_bound_authored_layers_across_sources() { let path = mutatorsans_designspace_path(); @@ -518,6 +598,20 @@ fn loads_designspace_sources_axes_and_default_metadata() { assert_eq!(font.sources()[0].location().get(&width_axis_id), Some(0.0)); assert_eq!(font.sources()[0].location().get(&weight_axis_id), Some(0.0)); assert!(font.sources()[0].filename().is_some()); + let bold_condensed = font + .sources() + .iter() + .find(|source| { + source.filename() == Some("MutatorSansBoldCondensed.ufo") + && source.layer_name().is_none() + }) + .expect("non-default Bold Condensed master should be imported"); + assert_eq!( + font.metric_value(bold_condensed.id(), MetricKind::Ascender) + .expect("non-default master ascender should survive import") + .position, + 800.0 + ); assert_eq!(font.named_instances().len(), 14); let medium_wide = font .named_instances() From 9113b09d10e05ed4f3be2dd583ee628f89f1bf42 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 09:50:10 +0000 Subject: [PATCH 20/29] Refine atomic layer replacement identities --- crates/shift-font/docs/DOCS.md | 2 +- crates/shift-font/src/ir/font.rs | 189 +++++++++++++++++++++++-------- 2 files changed, 145 insertions(+), 46 deletions(-) diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index 3385e6b8..4aa415b5 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -70,7 +70,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. - Own font authoring data structures such as `Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, `Source`, and `Axis`. - Keep object-level mutation behavior near the objects it mutates. -- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` validates a complete hydration/eviction batch before mutation, moves its validated identity sets into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. +- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` retains previous layers through cheap `Arc` references, validates a complete hydration/eviction batch into one `ReplacementIdentities` value, moves those sets into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. - Own canonical glyph and source-metric interpolation value ordering, variation-model construction, interpolation evaluation, and location-bound glyph resolution. - Stay independent of TypeScript, NAPI, and bridge DTOs. diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index 47a7cb01..d4236de3 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -120,6 +120,15 @@ struct FontData { images: BinaryData, } +#[derive(Default)] +struct ReplacementIdentities { + contours: HashSet, + points: HashSet, + components: HashSet, + anchors: HashSet, + guidelines: HashSet, +} + #[derive(Clone, Debug, Default)] struct FontIndex { glyph_by_name: HashMap, @@ -277,13 +286,8 @@ impl FontIndex { fn validate_layer_replacements( &self, - replacements: &[(GlyphId, GlyphLayer, GlyphLayer)], - replacement_contour_ids: &mut HashSet, - replacement_point_ids: &mut HashSet, - replacement_component_ids: &mut HashSet, - replacement_anchor_ids: &mut HashSet, - replacement_guideline_ids: &mut HashSet, - ) -> CoreResult<()> { + replacements: &[(GlyphId, Arc, GlyphLayer)], + ) -> CoreResult { let mut removed_contour_ids = HashSet::new(); let mut removed_point_ids = HashSet::new(); let mut removed_component_ids = HashSet::new(); @@ -300,12 +304,13 @@ impl FontIndex { removed_guideline_ids.extend(previous.guidelines().iter().map(Guideline::id)); } + let mut replacement_ids = ReplacementIdentities::default(); for (_, _, replacement) in replacements { for contour in replacement.contours_iter() { let contour_id = contour.id(); if (self.contour_ids.contains(&contour_id) && !removed_contour_ids.contains(&contour_id)) - || !replacement_contour_ids.insert(contour_id.clone()) + || !replacement_ids.contours.insert(contour_id.clone()) { return Err(CoreError::DuplicateContourId(contour_id)); } @@ -314,7 +319,7 @@ impl FontIndex { let point_id = point.id(); if (self.point_ids.contains(&point_id) && !removed_point_ids.contains(&point_id)) - || !replacement_point_ids.insert(point_id.clone()) + || !replacement_ids.points.insert(point_id.clone()) { return Err(CoreError::DuplicatePointId(point_id)); } @@ -325,7 +330,7 @@ impl FontIndex { let component_id = component.id(); if (self.component_ids.contains(&component_id) && !removed_component_ids.contains(&component_id)) - || !replacement_component_ids.insert(component_id.clone()) + || !replacement_ids.components.insert(component_id.clone()) { return Err(CoreError::DuplicateComponentId(component_id)); } @@ -335,7 +340,7 @@ impl FontIndex { let anchor_id = anchor.id(); if (self.anchor_ids.contains(&anchor_id) && !removed_anchor_ids.contains(&anchor_id)) - || !replacement_anchor_ids.insert(anchor_id.clone()) + || !replacement_ids.anchors.insert(anchor_id.clone()) { return Err(CoreError::DuplicateAnchorId(anchor_id)); } @@ -345,14 +350,14 @@ impl FontIndex { let guideline_id = guideline.id(); if (self.guideline_ids.contains(&guideline_id) && !removed_guideline_ids.contains(&guideline_id)) - || !replacement_guideline_ids.insert(guideline_id.clone()) + || !replacement_ids.guidelines.insert(guideline_id.clone()) { return Err(CoreError::DuplicateGuidelineId(guideline_id)); } } } - Ok(()) + Ok(replacement_ids) } fn insert_layer(&mut self, glyph_id: GlyphId, layer: &GlyphLayer) { @@ -1205,9 +1210,12 @@ impl Font { .glyph_id_by_layer(layer_id.clone()) .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; let previous = self - .layer(layer_id.clone()) - .ok_or(CoreError::LayerNotFound(layer_id.clone()))? - .clone(); + .data() + .glyphs + .get(&glyph_id) + .and_then(|glyph| glyph.layers().get(&layer_id)) + .cloned() + .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; if layer.source_id() != previous.source_id() { return Err(CoreError::LayerSourceMismatch { layer_id, @@ -1218,19 +1226,7 @@ impl Font { replacements.push((glyph_id, previous, layer)); } - let mut replacement_contour_ids = HashSet::new(); - let mut replacement_point_ids = HashSet::new(); - let mut replacement_component_ids = HashSet::new(); - let mut replacement_anchor_ids = HashSet::new(); - let mut replacement_guideline_ids = HashSet::new(); - self.index().validate_layer_replacements( - &replacements, - &mut replacement_contour_ids, - &mut replacement_point_ids, - &mut replacement_component_ids, - &mut replacement_anchor_ids, - &mut replacement_guideline_ids, - )?; + let replacement_ids = self.index().validate_layer_replacements(&replacements)?; let state = self.state_mut(); for (glyph_id, previous, _) in &replacements { @@ -1249,11 +1245,11 @@ impl Font { .expect("replacement owner was resolved before mutation"); Arc::make_mut(glyph).set_layer(layer); } - state.index.contour_ids.extend(replacement_contour_ids); - state.index.point_ids.extend(replacement_point_ids); - state.index.component_ids.extend(replacement_component_ids); - state.index.anchor_ids.extend(replacement_anchor_ids); - state.index.guideline_ids.extend(replacement_guideline_ids); + state.index.contour_ids.extend(replacement_ids.contours); + state.index.point_ids.extend(replacement_ids.points); + state.index.component_ids.extend(replacement_ids.components); + state.index.anchor_ids.extend(replacement_ids.anchors); + state.index.guideline_ids.extend(replacement_ids.guidelines); Ok(()) } @@ -1516,8 +1512,8 @@ fn validate_axis_mappings(axes: &[Axis], mappings: &[AxisMapping]) -> CoreResult mod tests { use super::*; use crate::{ - test_support::sample_font, AxisMappingPoint, AxisRole, Contour, ContourId, GlyphLayer, - LayerId, PointId, PointType, + test_support::sample_font, Anchor, AxisMappingPoint, AxisRole, Component, Contour, + ContourId, GlyphLayer, LayerId, PointId, PointType, }; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -1865,11 +1861,35 @@ mod tests { let mut font = Font::new(); let source_id = font.default_source_id().unwrap(); let contour_id = ContourId::new(); + let point_id = PointId::new(); + let component_id = ComponentId::new(); + let anchor_id = AnchorId::new(); + let guideline_id = GuidelineId::new(); + let base_glyph_id = GlyphId::new(); let mut contour = Contour::with_id(contour_id.clone()); - contour.add_point(0.0, 0.0, PointType::OnCurve, false); - let point_id = contour.points()[0].id(); + contour.add_point_with_id(point_id.clone(), 0.0, 0.0, PointType::OnCurve, false); let mut first_layer = GlyphLayer::with_width(LayerId::new(), source_id.clone(), 500.0); first_layer.add_contour(contour); + first_layer.add_component(Component::with_id( + component_id.clone(), + base_glyph_id.clone(), + "base", + crate::DecomposedTransform::default(), + )); + first_layer.add_anchor(Anchor::with_id( + anchor_id.clone(), + Some("top".to_string()), + 0.0, + 100.0, + )); + first_layer.add_guideline(Guideline::with_id( + guideline_id.clone(), + None, + Some(0.0), + None, + None, + None, + )); let first_layer_id = first_layer.id(); let mut first_glyph = Glyph::new("A"); first_glyph.set_layer(first_layer); @@ -1887,21 +1907,100 @@ mod tests { font.replace_glyph_layers(vec![replacement]).unwrap(); assert_eq!(font.layer(first_layer_id.clone()).unwrap().width(), 700.0); - assert_eq!(snapshot.layer(first_layer_id).unwrap().width(), 500.0); + assert_eq!( + snapshot.layer(first_layer_id.clone()).unwrap().width(), + 500.0 + ); assert!(font.has_contour_id(&contour_id)); assert!(font.has_point_id(&point_id)); - - let mut conflicting_contour = Contour::with_id(contour_id.clone()); - conflicting_contour.add_point(10.0, 10.0, PointType::OnCurve, false); + assert!(font.has_anchor_id(&anchor_id)); + + let mut conflicting_contour = Contour::new(); + conflicting_contour.add_point_with_id( + point_id.clone(), + 10.0, + 10.0, + PointType::OnCurve, + false, + ); let mut conflicting_layer = - GlyphLayer::with_width(second_layer_id.clone(), source_id, 800.0); + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0); conflicting_layer.add_contour(conflicting_contour); + assert!(matches!( + font.replace_glyph_layers(vec![conflicting_layer]), + Err(CoreError::DuplicatePointId(id)) if id == point_id + )); + let mut conflicting_layer = + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0); + conflicting_layer.add_component(Component::with_id( + component_id.clone(), + base_glyph_id.clone(), + "base", + crate::DecomposedTransform::default(), + )); assert!(matches!( font.replace_glyph_layers(vec![conflicting_layer]), - Err(CoreError::DuplicateContourId(id)) if id == contour_id + Err(CoreError::DuplicateComponentId(id)) if id == component_id )); - assert_eq!(font.layer(second_layer_id).unwrap().width(), 600.0); + + let mut conflicting_layer = + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0); + conflicting_layer.add_anchor(Anchor::with_id(anchor_id.clone(), None, 10.0, 10.0)); + assert!(matches!( + font.replace_glyph_layers(vec![conflicting_layer]), + Err(CoreError::DuplicateAnchorId(id)) if id == anchor_id + )); + + let mut conflicting_layer = + GlyphLayer::with_width(second_layer_id.clone(), source_id.clone(), 800.0); + conflicting_layer.add_guideline(Guideline::with_id( + guideline_id.clone(), + Some(10.0), + None, + None, + None, + None, + )); + assert!(matches!( + font.replace_glyph_layers(vec![conflicting_layer]), + Err(CoreError::DuplicateGuidelineId(id)) if id == guideline_id + )); + assert_eq!(font.layer(second_layer_id.clone()).unwrap().width(), 600.0); + + let emptied = GlyphLayer::with_width(first_layer_id, source_id.clone(), 700.0); + let mut transferred_contour = Contour::with_id(contour_id.clone()); + transferred_contour.add_point_with_id( + point_id.clone(), + 20.0, + 20.0, + PointType::OnCurve, + false, + ); + let mut transferred = GlyphLayer::with_width(second_layer_id.clone(), source_id, 800.0); + transferred.add_contour(transferred_contour); + transferred.add_component(Component::with_id( + component_id, + base_glyph_id, + "base", + crate::DecomposedTransform::default(), + )); + transferred.add_anchor(Anchor::with_id(anchor_id.clone(), None, 20.0, 20.0)); + transferred.add_guideline(Guideline::with_id( + guideline_id, + None, + Some(20.0), + None, + None, + None, + )); + font.replace_glyph_layers(vec![emptied, transferred]) + .unwrap(); + + assert_eq!(font.layer(second_layer_id).unwrap().width(), 800.0); + assert!(font.has_contour_id(&contour_id)); + assert!(font.has_point_id(&point_id)); + assert!(font.has_anchor_id(&anchor_id)); } #[test] From cce00677e45cf26ea1585ff6bffacbb6e4c3c6ff Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 09:51:49 +0000 Subject: [PATCH 21/29] Tighten canonical layer validation --- crates/shift-glyph-codec/SPECIFICATION.md | 2 +- crates/shift-glyph-codec/src/layer.rs | 16 ++++- crates/shift-glyph-codec/tests/layer.rs | 78 +++++++++++++++++++++++ packages/glyph-codec/src/layer.test.ts | 14 ++++ packages/glyph-codec/src/layer.ts | 18 +++++- 5 files changed, 121 insertions(+), 7 deletions(-) diff --git a/crates/shift-glyph-codec/SPECIFICATION.md b/crates/shift-glyph-codec/SPECIFICATION.md index 5d0fb414..cddeff9a 100644 --- a/crates/shift-glyph-codec/SPECIFICATION.md +++ b/crates/shift-glyph-codec/SPECIFICATION.md @@ -306,7 +306,7 @@ iteration: - 4,000,000 points; - 1,000,000 components, anchors, and guidelines each; - 4,000,000 distinct strings and 64 MiB combined UTF-8; -- 1,000,000 nested lib values and nesting depth 64; +- 1,000,000 nested lib values and recursion depth 64; each value and each dictionary container consumes one recursion level, while an array consumes its value level plus one level per nested value; - 256 MiB total payload bytes. The Rust borrowed view and TypeScript iterable view expose one layer's structure diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs index c02d9d0e..c7301106 100644 --- a/crates/shift-glyph-codec/src/layer.rs +++ b/crates/shift-glyph-codec/src/layer.rs @@ -1054,8 +1054,7 @@ pub fn pack_layer(layer: &GlyphLayer) -> Result encode_map(bytes, values, strings, depth + 1, count)?, LayerLibValue::Data(value) => { push_u32(bytes, value.len())?; + let payload_len = bytes + .len() + .checked_add(value.len()) + .ok_or(LayerCodecError::LengthOverflow)?; + check_limit("payload byte length", payload_len, MAX_LAYER_PAYLOAD_BYTES)?; bytes.extend_from_slice(value); } } diff --git a/crates/shift-glyph-codec/tests/layer.rs b/crates/shift-glyph-codec/tests/layer.rs index cb8872c6..21e7be08 100644 --- a/crates/shift-glyph-codec/tests/layer.rs +++ b/crates/shift-glyph-codec/tests/layer.rs @@ -138,6 +138,21 @@ fn string_section_end(bytes: &[u8]) -> usize { 72 + (count + 1) * 4 + string_bytes } +fn string_ranges(bytes: &[u8]) -> Vec> { + let count = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; + let values_offset = 72 + (count + 1) * 4; + let mut previous = 0; + (0..count) + .map(|index| { + let offset = 72 + (index + 1) * 4; + let end = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; + let range = values_offset + previous..values_offset + end; + previous = end; + range + }) + .collect() +} + #[test] fn shared_golden_vectors_decode_and_reencode_byte_for_byte() { let expected = full_layer(); @@ -322,6 +337,69 @@ fn encoder_rejects_nonfinite_numbers_and_duplicate_identity() { )); } +#[test] +fn decoder_rejects_deterministic_hostile_string_reference_tag_and_count_cases() { + let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); + + let mut invalid_utf8 = bytes.clone(); + let first_string_start = string_ranges(&invalid_utf8)[0].start; + invalid_utf8[first_string_start] = 0xff; + assert_eq!( + decode_layer(&invalid_utf8).unwrap_err(), + LayerCodecError::InvalidUtf8 { index: 0 } + ); + + let ranges = string_ranges(&bytes); + let (first, duplicate) = ranges + .iter() + .enumerate() + .flat_map(|(first_index, first)| { + ranges + .iter() + .enumerate() + .skip(first_index + 1) + .map(move |(_, duplicate)| (first, duplicate)) + }) + .find(|(first, duplicate)| first.len() == duplicate.len()) + .expect("fixture should contain two equally sized strings"); + let mut duplicate_string = bytes.clone(); + let first_bytes = duplicate_string[first.clone()].to_vec(); + duplicate_string[duplicate.clone()].copy_from_slice(&first_bytes); + assert!(matches!( + decode_layer(&duplicate_string), + Err(LayerCodecError::DuplicateString { .. }) + )); + + let mut noncanonical_reference = bytes.clone(); + noncanonical_reference[44..48].copy_from_slice(&2_u32.to_le_bytes()); + assert!(matches!( + decode_layer(&noncanonical_reference), + Err(LayerCodecError::NonCanonicalStringReference { + expected_at_most: 0, + actual: 2 + }) + )); + + let lib_bytes = u32::from_le_bytes(bytes[40..44].try_into().unwrap()) as usize; + let mut unknown_tag = bytes.clone(); + let lib_offset = unknown_tag.len() - lib_bytes; + unknown_tag[lib_offset + 8] = 0xff; + assert!(matches!( + decode_layer(&unknown_tag), + Err(LayerCodecError::UnknownLibTag { value: 0xff, .. }) + )); + + let mut excessive_points = bytes; + excessive_points[24..28].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!( + decode_layer(&excessive_points), + Err(LayerCodecError::LimitExceeded { + field: "point count", + .. + }) + )); +} + #[test] fn mutated_payloads_never_panic_and_accepted_bytes_reencode_canonically() { let seed = pack_layer(&full_layer()).unwrap().into_bytes(); diff --git a/packages/glyph-codec/src/layer.test.ts b/packages/glyph-codec/src/layer.test.ts index 3d8114af..e77de943 100644 --- a/packages/glyph-codec/src/layer.test.ts +++ b/packages/glyph-codec/src/layer.test.ts @@ -182,6 +182,20 @@ describe("shift.glyph-layer.v1", () => { expect([...packLayer(first).toUint8Array()]).toEqual([...packLayer(second).toUint8Array()]); }); + it("orders dictionary keys by UTF-8 bytes across the BMP boundary", () => { + const layer: GlyphLayer = { + ...emptyLayer(), + lib: new Map([ + ["\u{10000}", { kind: "string", value: "astral" }], + ["\uffff", { kind: "string", value: "bmp" }], + ]), + }; + + const decoded = decodeLayer(packLayer(layer).toUint8Array()).unpack(); + + expect([...decoded.lib.keys()]).toEqual(["\uffff", "\u{10000}"]); + }); + it("does not expose mutable validated storage", () => { const source = packLayer(fullLayer()).toUint8Array(); const layer = decodeLayer(source); diff --git a/packages/glyph-codec/src/layer.ts b/packages/glyph-codec/src/layer.ts index 854c0739..9536672f 100644 --- a/packages/glyph-codec/src/layer.ts +++ b/packages/glyph-codec/src/layer.ts @@ -462,14 +462,26 @@ function validateLayerInput(layer: GlyphLayer): void { unique("contour", contourIndex, contour.id); for (const point of contour.points) { unique("point", pointIndex, point.id); + validateFinite(point.x, "point coordinate", pointIndex * 2); + validateFinite(point.y, "point coordinate", pointIndex * 2 + 1); pointIndex += 1; } } - for (const [index, component] of layer.components.entries()) + for (const [index, component] of layer.components.entries()) { unique("component", index, component.id); - for (const [index, anchor] of layer.anchors.entries()) unique("anchor", index, anchor.id); - for (const [index, guideline] of layer.guidelines.entries()) + for (const [valueIndex, value] of transformValues(component.transform).entries()) + validateFinite(value, "component transform", index * 9 + valueIndex); + } + for (const [index, anchor] of layer.anchors.entries()) { + unique("anchor", index, anchor.id); + validateFinite(anchor.x, "anchor coordinate", index * 2); + validateFinite(anchor.y, "anchor coordinate", index * 2 + 1); + } + for (const [index, guideline] of layer.guidelines.entries()) { unique("guideline", index, guideline.id); + for (const [valueIndex, value] of [guideline.x, guideline.y, guideline.angle].entries()) + if (value !== null) validateFinite(value, "guideline number", index * 3 + valueIndex); + } } function validate(bytes: Uint8Array): Header { From 3a0a0be9ac0f163592a0e97032e852a332a0c065 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 09:52:59 +0000 Subject: [PATCH 22/29] Share bounded layer loads and stage imports --- Cargo.lock | 1 + crates/shift-font/src/changes.rs | 12 +- crates/shift-font/src/intents.rs | 37 +-- crates/shift-store/README.md | 6 +- crates/shift-store/src/change_set.rs | 9 +- crates/shift-store/src/connection.rs | 4 +- crates/shift-store/src/error.rs | 3 - crates/shift-store/src/font_state.rs | 58 +--- crates/shift-store/src/packed_layer.rs | 279 +++++++++++++++--- crates/shift-store/src/schema.rs | 1 - crates/shift-store/src/stream_writer.rs | 9 +- crates/shift-store/tests/store_test.rs | 80 +++-- crates/shift-workspace/Cargo.toml | 3 +- crates/shift-workspace/docs/DOCS.md | 5 +- crates/shift-workspace/src/import_staging.rs | 55 ++++ crates/shift-workspace/src/lib.rs | 1 + crates/shift-workspace/src/workspace.rs | 45 ++- .../shift-workspace/tests/workspace_test.rs | 135 ++++++++- 18 files changed, 535 insertions(+), 208 deletions(-) create mode 100644 crates/shift-workspace/src/import_staging.rs diff --git a/Cargo.lock b/Cargo.lock index 882e2f37..39d700e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2429,6 +2429,7 @@ dependencies = [ name = "shift-workspace" version = "0.1.0" dependencies = [ + "rusqlite", "shift-backends", "shift-font", "shift-source", diff --git a/crates/shift-font/src/changes.rs b/crates/shift-font/src/changes.rs index e4b140c4..8799cf18 100644 --- a/crates/shift-font/src/changes.rs +++ b/crates/shift-font/src/changes.rs @@ -134,12 +134,8 @@ impl FontChange { }) } - pub fn glyph_layer_created( - glyph_id: GlyphId, - name: Option, - layer: &GlyphLayer, - ) -> Self { - Self::GlyphLayerCreated(GlyphLayerCreated::from_layer(glyph_id, name, layer)) + pub fn glyph_layer_created(glyph_id: GlyphId, layer: &GlyphLayer) -> Self { + Self::GlyphLayerCreated(GlyphLayerCreated::from_layer(glyph_id, layer)) } pub fn glyph_layer_deleted(glyph_id: GlyphId, layer: &GlyphLayer) -> Self { @@ -381,18 +377,16 @@ pub struct GlyphLayerCreated { pub glyph_id: GlyphId, pub source_id: SourceId, pub layer_id: LayerId, - pub name: Option, pub width: f64, pub height: Option, } impl GlyphLayerCreated { - pub fn from_layer(glyph_id: GlyphId, name: Option, layer: &GlyphLayer) -> Self { + pub fn from_layer(glyph_id: GlyphId, layer: &GlyphLayer) -> Self { Self { glyph_id, source_id: layer.source_id(), layer_id: layer.id(), - name, width: layer.width(), height: layer.height(), } diff --git a/crates/shift-font/src/intents.rs b/crates/shift-font/src/intents.rs index 611547fb..138b2302 100644 --- a/crates/shift-font/src/intents.rs +++ b/crates/shift-font/src/intents.rs @@ -702,11 +702,6 @@ impl Font { source_id: SourceId, changes: &mut FontChangeSet, ) -> CoreResult> { - let glyph_name = self - .glyph(glyph_id.clone()) - .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))? - .glyph_name() - .clone(); if self.glyph_id_by_layer(layer_id.clone()).is_some() { return Err(CoreError::DuplicateLayerId(layer_id)); } @@ -722,11 +717,7 @@ impl Font { let layer = GlyphLayer::with_width(layer_id.clone(), source_id, self.default_layer_width()); self.insert_glyph_layer(glyph_id.clone(), layer.clone())?; - changes.push(FontChange::glyph_layer_created( - glyph_id, - Some(glyph_name), - &layer, - )); + changes.push(FontChange::glyph_layer_created(glyph_id, &layer)); Ok(vec![layer_id]) } @@ -739,15 +730,11 @@ impl Font { from_layer_id: LayerId, changes: &mut FontChangeSet, ) -> CoreResult> { - let (glyph_name, layer) = + let layer = self.cloned_glyph_layer(layer_id.clone(), glyph_id.clone(), source_id, from_layer_id)?; self.insert_glyph_layer(glyph_id.clone(), layer.clone())?; - changes.push(FontChange::glyph_layer_created( - glyph_id, - Some(glyph_name), - &layer, - )); + changes.push(FontChange::glyph_layer_created(glyph_id, &layer)); Ok(vec![layer_id]) } @@ -761,16 +748,12 @@ impl Font { values: &GlyphInterpolationValues, changes: &mut FontChangeSet, ) -> CoreResult> { - let (glyph_name, mut layer) = + let mut layer = self.cloned_glyph_layer(layer_id.clone(), glyph_id.clone(), source_id, from_layer_id)?; layer.apply_interpolation_values(values)?; self.insert_glyph_layer(glyph_id.clone(), layer.clone())?; - changes.push(FontChange::glyph_layer_created( - glyph_id, - Some(glyph_name), - &layer, - )); + changes.push(FontChange::glyph_layer_created(glyph_id, &layer)); Ok(vec![layer_id]) } @@ -781,13 +764,7 @@ impl Font { glyph_id: GlyphId, source_id: SourceId, from_layer_id: LayerId, - ) -> CoreResult<(GlyphName, GlyphLayer)> { - let glyph_name = self - .glyph(glyph_id.clone()) - .ok_or_else(|| CoreError::GlyphNotFound(glyph_id.clone()))? - .glyph_name() - .clone(); - + ) -> CoreResult { if self.glyph_id_by_layer(layer_id.clone()).is_some() { return Err(CoreError::DuplicateLayerId(layer_id)); } @@ -817,7 +794,7 @@ impl Font { .ok_or(CoreError::LayerNotFound(from_layer_id))?; let layer = source_layer.clone_with_fresh_ids(layer_id, source_id); - Ok((glyph_name, layer)) + Ok(layer) } fn apply_update_glyph( diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 08e491ec..40aa8d4f 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -6,13 +6,13 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se ## Storage boundary -- Font metadata and the glyph/layer directory remain relational and load eagerly. +- Font metadata and the glyph/layer directory remain relational and load eagerly. Layer rows reference their glyph rather than duplicating its editable name. - Each editable glyph layer is one independently addressable payload. Its canonical decoded bytes remain `shift.glyph-layer.v1`; the store independently wraps them as `none` or `zstd.v1` without changing authored semantics. - Component dependency rows contain only the earned query index: component ID, owner layer ID, base glyph ID, and ordinal. - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` scans directory facts, bounded payloads, and reference rows once for a deduplicated batch of at most 512 layers and 256 MiB decoded bytes, then decompresses and validates the batch with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict canonical decoding. Explicit full-font materialization partitions the directory through that same bounded loader instead of issuing one query sequence per layer. All payload paths cross-check canonical bytes against relational facts. `FontWorkspace` uses the batch path for explicit glyph acquisition and safe cache eviction. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` accepts a complete requested set, deduplicates it, and partitions it through the single count- and decoded-byte-aware planner. Each internal batch performs three ordered directory/payload/reference scans for at most 512 layers and 256 MiB decoded bytes, then decompresses and validates with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict canonical decoding. Full-font materialization and workspace acquisition use that same planner; no caller maintains a weaker count-only loop. All payload paths cross-check canonical bytes against relational facts. ## Outer payload contract @@ -31,7 +31,7 @@ only then invoke strict canonical decoding. A layer replacement may move between `LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `pack_glyph_batch` canonical-encodes, BLAKE3-hashes, and independently compresses an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, packing/compression, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. -`ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the destination is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL before workspace state is published. Normal edits never use import pragmas. Progress, cancellation, source fingerprinting, and final atomic destination installation remain workspace responsibilities. +`ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the path is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL. Normal edits never use import pragmas. `FontWorkspace` opens imports at a sibling staging path, writes workspace state there, makes the staged database durable, closes it, atomically installs it at the requested destination, and syncs the parent directory. A failure before installation leaves the previous destination untouched. ## Schema policy diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index 207cb032..83d047e9 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -163,7 +163,7 @@ pub(crate) fn write_glyph_in_tx( write_glyph_directory_in_tx(tx, glyph, order_index, WriteMode::Upsert)?; for layer in glyph.layers().values().map(|layer| layer.as_ref()) { - write_layer_in_tx(tx, &glyph.id(), Some(glyph.glyph_name()), layer)?; + write_layer_in_tx(tx, &glyph.id(), layer)?; } Ok(()) } @@ -335,13 +335,16 @@ fn apply_change(tx: &Transaction<'_>, change: &font::FontChange) -> Result<(), S Ok(()) } font::FontChange::GlyphIdentityChanged(change) => { - upsert_glyph(tx, &change.glyph_id, &change.to_name, 0)?; + let rows_changed = tx.execute( + "UPDATE glyphs SET name = ?1 WHERE id = ?2", + params![change.to_name.as_str(), change.glyph_id.to_string()], + )?; + require_changed(rows_changed, "glyph", change.glyph_id.to_string())?; replace_glyph_unicodes(tx, &change.glyph_id, &change.to_unicodes) } font::FontChange::GlyphLayerCreated(change) => create_empty_layer_in_tx( tx, &change.glyph_id, - change.name.as_ref(), change.layer_id.clone(), change.source_id.clone(), change.width, diff --git a/crates/shift-store/src/connection.rs b/crates/shift-store/src/connection.rs index b9a6eb48..c4e6a214 100644 --- a/crates/shift-store/src/connection.rs +++ b/crates/shift-store/src/connection.rs @@ -36,8 +36,8 @@ impl ShiftStore { }) } - /// Flushes a completed base import and restores the normal edit-time WAL - /// posture before the workspace becomes authoritative. + /// Flushes a completed staged import and restores the normal edit-time + /// WAL posture before the workspace publishes the closed database. pub fn finish_import(&self) -> Result<(), StoreError> { self.sync_store_file()?; diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 5cc749f1..4390f82e 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -33,9 +33,6 @@ pub enum StoreError { #[error("glyph layer read batch has {layers} layers; limit is {limit}")] LayerReadBatchTooLarge { layers: usize, limit: usize }, - #[error("glyph layer read batch has {bytes} stored bytes; limit is {limit}")] - LayerReadBatchStoredTooLarge { bytes: u64, limit: u64 }, - #[error("glyph layer read batch has {bytes} decoded bytes; limit is {limit}")] LayerReadBatchDecodedTooLarge { bytes: u64, limit: u64 }, diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index 163e8cee..abdc99d2 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -365,11 +365,9 @@ fn load_glyphs( let layer_rows = { let mut stmt = conn.prepare( " - SELECT l.id, l.glyph_id, l.source_id, l.width, l.height, - p.decoded_byte_length - FROM glyph_layers AS l - LEFT JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - ORDER BY l.glyph_id, l.source_id, l.id + SELECT id, glyph_id, source_id, width, height + FROM glyph_layers + ORDER BY glyph_id, source_id, id ", )?; stmt.query_map([], |row| { @@ -379,43 +377,23 @@ fn load_glyphs( font::SourceId::from_raw(row.get::<_, String>(2)?), row.get::<_, f64>(3)?, row.get::<_, Option>(4)?, - row.get::<_, Option>(5)?, )) })? .collect::, _>>()? }; let mut loaded_layers = HashMap::new(); if include_layer_payloads { - let mut batch = Vec::new(); - let mut batch_bytes = 0_u64; - for (layer_id, _, _, _, _, decoded_byte_length) in &layer_rows { - let decoded_byte_length = - decoded_byte_length.ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer payload", - id: layer_id.to_string(), - })?; - let decoded_byte_length = u64::try_from(decoded_byte_length).map_err(|_| { - StoreError::LayerDirectoryMismatch { - layer_id: layer_id.to_string(), - detail: format!("negative decoded byte length {decoded_byte_length}"), - } - })?; - if !batch.is_empty() - && (batch.len() == crate::MAX_LAYER_READ_BATCH_COUNT - || batch_bytes.saturating_add(decoded_byte_length) - > crate::MAX_LAYER_READ_BATCH_DECODED_BYTES) - { - load_layer_batch(conn, &mut batch, &mut loaded_layers)?; - batch_bytes = 0; - } - batch.push(layer_id.clone()); - batch_bytes = batch_bytes.saturating_add(decoded_byte_length); + let layer_ids = layer_rows + .iter() + .map(|(layer_id, _, _, _, _)| layer_id.clone()) + .collect::>(); + for layer in crate::packed_layer::load_glyph_layers_from_conn(conn, &layer_ids)? { + loaded_layers.insert(layer.id(), layer); } - load_layer_batch(conn, &mut batch, &mut loaded_layers)?; } let mut layers: HashMap> = HashMap::new(); - for (layer_id, glyph_id, source_id, width, height, _) in layer_rows { + for (layer_id, glyph_id, source_id, width, height) in layer_rows { let layer = if include_layer_payloads { loaded_layers .remove(&layer_id) @@ -451,22 +429,6 @@ fn load_glyphs( Ok(glyphs) } -fn load_layer_batch( - conn: &rusqlite::Connection, - batch: &mut Vec, - loaded: &mut HashMap, -) -> Result<(), StoreError> { - if batch.is_empty() { - return Ok(()); - } - - let layer_ids = std::mem::take(batch); - for layer in crate::packed_layer::load_glyph_layers_from_conn(conn, &layer_ids)? { - loaded.insert(layer.id(), layer); - } - Ok(()) -} - fn load_font_guidelines(conn: &rusqlite::Connection) -> Result, StoreError> { let mut stmt = conn.prepare( " diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index d3ae21e8..b2ff9206 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -39,8 +39,8 @@ impl ShiftStore { load_glyph_layer_from_conn(&self.conn, layer_id) } - /// Fetches and cross-validates a bounded layer batch with three ordered - /// SQLite scans rather than three query executions per layer. + /// Fetches and cross-validates requested layers through count- and + /// decoded-byte-bounded batches. pub fn load_glyph_layers( &self, layer_ids: &[font::LayerId], @@ -58,7 +58,7 @@ impl ShiftStore { id: layer.id().to_string(), })?; let tx = self.conn.transaction()?; - write_layer_in_tx(&tx, &owner.0, owner.1.as_ref(), layer)?; + write_layer_in_tx(&tx, &owner, layer)?; mark_workspace_dirty_in_tx(&tx)?; tx.commit()?; Ok(()) @@ -67,9 +67,10 @@ impl ShiftStore { pub fn list_glyph_layer_directory(&self) -> Result, StoreError> { let mut stmt = self.conn.prepare( " - SELECT l.id, l.glyph_id, l.source_id, l.name, l.width, l.height, + SELECT l.id, l.glyph_id, l.source_id, g.name, l.width, l.height, p.stored_byte_length, p.decoded_byte_length FROM glyph_layers AS l + JOIN glyphs AS g ON g.id = l.glyph_id JOIN glyph_layer_payloads AS p ON p.layer_id = l.id ORDER BY l.glyph_id, l.source_id, l.id ", @@ -238,7 +239,6 @@ impl DirectoryFacts { } fn stored_layer(&self, payload: Vec) -> Result { - self.validate()?; let decoded_blake3: [u8; 32] = self.decoded_blake3.as_slice().try_into().map_err(|_| { StoreError::LayerDirectoryMismatch { layer_id: self.layer_id.clone(), @@ -345,6 +345,80 @@ pub(crate) fn load_glyph_layers_from_conn( if keys.is_empty() { return Ok(Vec::new()); } + + let mut layers = Vec::with_capacity(keys.len()); + for count_batch in keys.chunks(MAX_LAYER_READ_BATCH_COUNT) { + let placeholders = (0..count_batch.len()) + .map(|_| "?") + .collect::>() + .join(","); + let sql = format!( + "SELECT l.id, p.decoded_byte_length + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id IN ({placeholders}) + ORDER BY l.id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(count_batch.iter()), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + let mut decoded_lengths = HashMap::with_capacity(count_batch.len()); + for row in rows { + let (layer_id, decoded_byte_length) = row?; + if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&decoded_byte_length) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(decoded_byte_length).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + decoded_lengths.insert(layer_id, decoded_byte_length as u64); + } + drop(stmt); + + let mut batch = Vec::new(); + let mut batch_decoded_bytes = 0_u64; + for key in count_batch { + let decoded_byte_length = + decoded_lengths + .get(key) + .copied() + .ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: key.clone(), + })?; + if !batch.is_empty() + && batch_decoded_bytes.saturating_add(decoded_byte_length) + > MAX_LAYER_READ_BATCH_DECODED_BYTES + { + layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + batch.clear(); + batch_decoded_bytes = 0; + } + batch.push(font::LayerId::from_raw(key.clone())); + batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); + } + if !batch.is_empty() { + layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + } + } + + Ok(layers) +} + +fn load_glyph_layer_batch_from_conn( + conn: &Connection, + layer_ids: &[font::LayerId], +) -> Result, StoreError> { + let mut keys = layer_ids + .iter() + .map(ToString::to_string) + .collect::>(); + keys.sort(); + keys.dedup(); + if keys.is_empty() { + return Ok(Vec::new()); + } if keys.len() > MAX_LAYER_READ_BATCH_COUNT { return Err(StoreError::LayerReadBatchTooLarge { layers: keys.len(), @@ -371,12 +445,10 @@ pub(crate) fn load_glyph_layers_from_conn( map_directory_facts_row, )?; let mut facts = HashMap::with_capacity(keys.len()); - let mut batch_stored_bytes = 0_u64; let mut batch_decoded_bytes = 0_u64; for row in directory_rows { let facts_row = row?; let decoded_byte_length = facts_row.validate()?; - batch_stored_bytes = batch_stored_bytes.saturating_add(facts_row.stored_byte_length as u64); batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); facts.insert(facts_row.layer_id.clone(), facts_row); } @@ -390,12 +462,6 @@ pub(crate) fn load_glyph_layers_from_conn( }); } } - if batch_stored_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { - return Err(StoreError::LayerReadBatchStoredTooLarge { - bytes: batch_stored_bytes, - limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, - }); - } if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { return Err(StoreError::LayerReadBatchDecodedTooLarge { bytes: batch_decoded_bytes, @@ -526,18 +592,16 @@ pub(crate) fn load_glyph_layers_from_conn( pub(crate) fn write_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, - name: Option<&font::GlyphName>, layer: &font::GlyphLayer, ) -> Result<(), StoreError> { let packed = font::pack_glyph_layer(layer)?; let stored = compress_glyph_layer(packed.as_bytes())?; - store_stored_layer_in_tx(tx, glyph_id, name, layer, &stored, WriteMode::Upsert) + store_stored_layer_in_tx(tx, glyph_id, layer, &stored, WriteMode::Upsert) } pub(crate) fn store_stored_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, - name: Option<&font::GlyphName>, layer: &font::GlyphLayer, stored: &StoredGlyphLayer, mode: WriteMode, @@ -546,16 +610,15 @@ pub(crate) fn store_stored_layer_in_tx( let decoded_byte_length = encoded_len(stored.decoded_byte_length)?; let layer_sql = match mode { WriteMode::Insert => { - "INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, ?5, ?6)" + "INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, ?4, ?5)" } WriteMode::Upsert => { " - INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) + VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(id) DO UPDATE SET glyph_id = excluded.glyph_id, source_id = excluded.source_id, - name = excluded.name, width = excluded.width, height = excluded.height " @@ -565,7 +628,6 @@ pub(crate) fn store_stored_layer_in_tx( layer.id().to_string(), glyph_id.to_string(), layer.source_id().to_string(), - name.map(font::GlyphName::as_str), layer.width(), layer.height(), ])?; @@ -620,13 +682,12 @@ pub(crate) fn rewrite_layer_in_tx( kind: "glyph layer", id: layer.id().to_string(), })?; - write_layer_in_tx(tx, &owner.0, owner.1.as_ref(), layer) + write_layer_in_tx(tx, &owner, layer) } pub(crate) fn create_empty_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, - name: Option<&font::GlyphName>, layer_id: font::LayerId, source_id: font::SourceId, width: f64, @@ -634,7 +695,7 @@ pub(crate) fn create_empty_layer_in_tx( ) -> Result<(), StoreError> { let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); layer.set_height(height); - write_layer_in_tx(tx, glyph_id, name, &layer) + write_layer_in_tx(tx, glyph_id, &layer) } fn write_component_index( @@ -738,16 +799,11 @@ fn validate_component_rows( fn layer_owner( conn: &Connection, layer_id: &font::LayerId, -) -> Result)>, StoreError> { +) -> Result, StoreError> { conn.query_row( - "SELECT glyph_id, name FROM glyph_layers WHERE id = ?1", + "SELECT glyph_id FROM glyph_layers WHERE id = ?1", [layer_id.to_string()], - |row| { - Ok(( - font::GlyphId::from_raw(row.get::<_, String>(0)?), - row.get::<_, Option>(1)?.map(font::GlyphName::from), - )) - }, + |row| Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)), ) .optional() .map_err(Into::into) @@ -829,7 +885,7 @@ mod tests { .unwrap(); let tx = store.conn.transaction().unwrap(); - write_layer_in_tx(&tx, &glyph_id, Some(&glyph_name), &layer).unwrap(); + write_layer_in_tx(&tx, &glyph_id, &layer).unwrap(); tx.commit().unwrap(); let stored = store @@ -884,14 +940,40 @@ mod tests { } #[test] - fn bounded_batch_load_rejects_excessive_layer_counts_before_sql() { + fn requested_layers_load_across_multiple_count_bounded_batches() { + let mut font = font::Font::new(); + let source_id = font.default_source_id().unwrap(); + let mut layer_ids = Vec::new(); + for index in 0..=MAX_LAYER_READ_BATCH_COUNT { + let layer = font::GlyphLayer::with_width( + font::LayerId::from_raw(format!("layer_{index:04}")), + source_id.clone(), + index as f64, + ); + layer_ids.push(layer.id()); + let mut glyph = font::Glyph::new(format!("glyph_{index:04}")); + glyph.set_layer(layer); + font.insert_glyph(glyph).unwrap(); + } + let mut store = ShiftStore::open_memory_for_test().unwrap(); + store.replace_font_state(&font).unwrap(); + + let layers = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(layers.len(), layer_ids.len()); + assert_eq!(layers.first().unwrap().id(), layer_ids[0]); + assert_eq!(layers.last().unwrap().id(), layer_ids[512]); + } + + #[test] + fn one_internal_batch_rejects_excessive_layer_counts_before_sql() { let store = ShiftStore::open_memory_for_test().unwrap(); let layer_ids = (0..=MAX_LAYER_READ_BATCH_COUNT) .map(|index| font::LayerId::from_raw(format!("layer_{index}"))) .collect::>(); assert!(matches!( - store.load_glyph_layers(&layer_ids), + load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), Err(StoreError::LayerReadBatchTooLarge { .. }) )); } @@ -918,7 +1000,7 @@ mod tests { } assert!(matches!( - store.load_glyph_layers(&layer_ids), + load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), Err(StoreError::LayerReadBatchDecodedTooLarge { .. }) )); } @@ -957,6 +1039,115 @@ mod tests { assert_eq!(loaded.len(), 2); assert_eq!(loaded[0].id(), layer_ids[0]); assert_eq!(loaded[1].id(), layer_ids[1]); + for layer in loaded { + assert_eq!(font.layer(layer.id()).unwrap(), &layer); + } + } + + #[test] + fn declared_stored_length_mismatch_is_rejected_before_decode() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .pragma_update(None, "ignore_check_constraints", "ON") + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET stored_byte_length = stored_byte_length + 1 + WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + store + .conn + .pragma_update(None, "ignore_check_constraints", "OFF") + .unwrap(); + + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("declared stored byte length") + )); + assert!(matches!( + store.load_glyph_layers(std::slice::from_ref(layer_id)), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("declared stored byte length") + )); + } + + #[test] + fn layer_rewrite_transitions_from_uncompressed_to_zstd_consistently() { + let (mut store, font) = populated_store(); + let layer = font + .glyphs() + .next() + .unwrap() + .layers() + .values() + .next() + .unwrap(); + let packed = font::pack_glyph_layer(layer).unwrap(); + let uncompressed = StoredGlyphLayer { + compression: crate::types::LayerPayloadCompression::None, + bytes: packed.as_bytes().to_vec(), + stored_byte_length: packed.as_bytes().len() as u64, + decoded_byte_length: packed.as_bytes().len() as u64, + decoded_blake3: *blake3::hash(packed.as_bytes()).as_bytes(), + }; + let glyph_id = font.glyph_id_by_layer(layer.id()).unwrap(); + let tx = store.conn.transaction().unwrap(); + store_stored_layer_in_tx(&tx, &glyph_id, layer, &uncompressed, WriteMode::Upsert).unwrap(); + tx.commit().unwrap(); + assert_eq!( + store + .conn + .query_row( + "SELECT compression FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "none" + ); + + store.replace_glyph_layer(layer).unwrap(); + + let (compression, stored, decoded, hash_length, payload_length) = store + .conn + .query_row( + "SELECT compression, stored_byte_length, decoded_byte_length, + length(decoded_blake3), length(payload) + FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + ) + .unwrap(); + assert_eq!(compression, "zstd.v1"); + assert_eq!(stored, payload_length); + assert!(stored < decoded); + assert_eq!(hash_length, 32); + assert_eq!( + store.load_glyph_layer(&layer.id()).unwrap().unwrap(), + **layer + ); } #[test] @@ -979,7 +1170,11 @@ mod tests { [layer_id.to_string()], ) .unwrap(); - assert!(store.load_glyph_layer(layer_id).is_err()); + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("decoded byte length") + )); let packed = font::pack_glyph_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); store @@ -991,7 +1186,11 @@ mod tests { params![packed.as_bytes().len() as i64, layer_id.to_string()], ) .unwrap(); - assert!(store.load_glyph_layer(layer_id).is_err()); + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("decoded BLAKE3 mismatch") + )); } #[test] @@ -1255,7 +1454,7 @@ mod tests { .prepare("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)") .unwrap(); let mut layer_stmt = tx - .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, name, width, height) VALUES (?1, ?2, ?3, ?4, 1000, NULL)") + .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, 1000, NULL)") .unwrap(); let mut payload_stmt = tx .prepare( @@ -1273,7 +1472,7 @@ mod tests { .execute(params![glyph_id, name, index as i64]) .unwrap(); layer_stmt - .execute(params![layer_id, glyph_id, source_id, name]) + .execute(params![layer_id, glyph_id, source_id]) .unwrap(); payload_stmt .execute(params![layer_id, GLYPH_LAYER_FORMAT]) diff --git a/crates/shift-store/src/schema.rs b/crates/shift-store/src/schema.rs index daaac69c..df8949d1 100644 --- a/crates/shift-store/src/schema.rs +++ b/crates/shift-store/src/schema.rs @@ -112,7 +112,6 @@ CREATE TABLE IF NOT EXISTS glyph_layers ( id TEXT PRIMARY KEY, glyph_id TEXT NOT NULL, source_id TEXT NOT NULL, - name TEXT, width REAL NOT NULL DEFAULT 0, height REAL, FOREIGN KEY (glyph_id) REFERENCES glyphs(id) ON DELETE CASCADE, diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index a0f32446..61c9ceb8 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -119,14 +119,7 @@ impl LayerStreamWriter<'_> { kind: "glyph layer", id: layer_id.to_string(), })?; - store_stored_layer_in_tx( - &self.tx, - &glyph.id(), - Some(glyph.glyph_name()), - layer, - stored, - WriteMode::Insert, - )?; + store_stored_layer_in_tx(&self.tx, &glyph.id(), layer, stored, WriteMode::Insert)?; } self.next_glyph_order += 1; Ok(()) diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 419a80a7..47623f96 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -292,10 +292,7 @@ fn creates_and_reads_glyph_layer() { assert_eq!(layer.glyph_id.as_str(), glyph_id.as_str()); assert_eq!(layer.source_id.as_str(), source_id.as_str()); - assert_eq!( - layer.name.as_ref().map(|name| name.as_str()), - Some("Regular") - ); + assert_eq!(layer.name.as_ref().map(|name| name.as_str()), Some("A")); } #[test] @@ -310,7 +307,6 @@ fn glyph_layer_requires_existing_glyph_and_source() { let result = store.apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_layer_created( shift_font::GlyphId::from_raw("glyph-missing"), - Some(shift_font::GlyphName::from("Regular")), &layer, ), ])); @@ -405,10 +401,15 @@ fn applies_glyph_identity_change_set() { let mut store = ShiftStore::open_memory_for_test().expect("memory store should open"); let glyph = shift_font::Glyph::with_unicode("A", 65); let glyph_id = glyph.id(); + let source_id = shift_font::SourceId::new(); + let layer = + shift_font::GlyphLayer::with_width(shift_font::LayerId::new(), source_id.clone(), 500.0); + create_regular_source_with_id(&mut store, source_id); store .apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::GlyphCreated(shift_font::GlyphCreated::from(&glyph)), + shift_font::FontChange::glyph_layer_created(glyph.id(), &layer), shift_font::FontChange::GlyphIdentityChanged(shift_font::GlyphIdentityChanged { glyph_id: glyph_id.clone(), from_name: shift_font::GlyphName::from("A"), @@ -427,8 +428,46 @@ fn applies_glyph_identity_change_set() { .list_glyph_unicodes(&stored.id) .expect("unicode query should succeed"); + let directory = store.list_glyph_layer_directory().unwrap(); + assert_eq!(stored.name.as_deref(), Some("A.alt")); assert_eq!(unicodes, vec![0x00c1]); + assert_eq!(directory.len(), 1); + assert_eq!(directory[0].name.as_deref(), Some("A.alt")); +} + +#[test] +fn glyph_rename_preserves_authored_order() { + let mut font = shift_font::Font::new(); + let first = shift_font::Glyph::new("first"); + let second = shift_font::Glyph::new("second"); + let second_id = second.id(); + font.insert_glyph(first).unwrap(); + font.insert_glyph(second).unwrap(); + let mut store = ShiftStore::open_memory_for_test().unwrap(); + store.replace_font_state(&font).unwrap(); + + store + .apply_change_set(&shift_font::FontChangeSet::new(vec![ + shift_font::FontChange::GlyphIdentityChanged(shift_font::GlyphIdentityChanged { + glyph_id: second_id, + from_name: shift_font::GlyphName::from("second"), + to_name: shift_font::GlyphName::from("second.alt"), + from_unicodes: vec![], + to_unicodes: vec![], + }), + ])) + .unwrap(); + + assert_eq!( + store + .load_font_directory() + .unwrap() + .glyphs() + .map(|glyph| glyph.name().to_string()) + .collect::>(), + vec!["first", "second.alt"] + ); } #[test] @@ -444,11 +483,7 @@ fn applies_glyph_delete_change_set_and_cascades_layers() { store .apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_created(&glyph), - shift_font::FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - &layer, - ), + shift_font::FontChange::glyph_layer_created(glyph.id(), &layer), shift_font::FontChange::glyph_deleted(glyph.id()), ])) .expect("change set should apply"); @@ -480,11 +515,7 @@ fn applies_layer_metrics_and_contour_point_changes() { store .apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_created(&glyph), - shift_font::FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - &layer, - ), + shift_font::FontChange::glyph_layer_created(glyph.id(), &layer), shift_font::FontChange::LayerMetricsChanged(shift_font::LayerMetricsChanged { layer_id: layer.id(), width: 720.0, @@ -533,11 +564,7 @@ fn applies_layer_geometry_replacement() { store .apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_created(&glyph), - shift_font::FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - &layer, - ), + shift_font::FontChange::glyph_layer_created(glyph.id(), &layer), shift_font::FontChange::ContourAdded(shift_font::ContourAdded { layer_id: layer.id(), contour: first_contour, @@ -680,11 +707,7 @@ fn rejects_incremental_change_for_missing_point_row() { let result = store.apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_created(&glyph), - shift_font::FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - &layer, - ), + shift_font::FontChange::glyph_layer_created(glyph.id(), &layer), shift_font::FontChange::PointPositionsChanged(shift_font::PointPositionsChanged { layer_id: layer.id(), points: vec![shift_font::PointPosition { @@ -821,7 +844,6 @@ fn create_default_glyph_layer( .apply_change_set(&shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_layer_created( shift_font::GlyphId::from_raw(glyph_id.as_str()), - Some(shift_font::GlyphName::from("Regular")), &layer, ), ])) @@ -946,11 +968,7 @@ fn anchored_layer_change_set( ) -> shift_font::FontChangeSet { shift_font::FontChangeSet::new(vec![ shift_font::FontChange::glyph_created(glyph), - shift_font::FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - layer, - ), + shift_font::FontChange::glyph_layer_created(glyph.id(), layer), shift_font::FontChange::layer_geometry_replaced(layer), ]) } diff --git a/crates/shift-workspace/Cargo.toml b/crates/shift-workspace/Cargo.toml index 06425016..20d7c506 100644 --- a/crates/shift-workspace/Cargo.toml +++ b/crates/shift-workspace/Cargo.toml @@ -8,10 +8,11 @@ shift-backends = { workspace = true } shift-font = { workspace = true } shift-source = { workspace = true } shift-store = { workspace = true } +tempfile = "3" thiserror = "2" [target.'cfg(windows)'.dependencies] windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem"] } [dev-dependencies] -tempfile = "3" +rusqlite = "0.37.0" diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 71f935c0..65845547 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -24,6 +24,7 @@ crates/shift-workspace/src/ new_workspace.rs -- creation options for a fresh workspace source_identity.rs -- package identity snapshots and source-save validation import_pipeline.rs -- bounded parser/parallel-packer/single-writer pipeline and progress observer + import_staging.rs -- sibling staged-store creation, atomic installation, and parent sync layer_residency.rs -- complete read sets, bounded acquisition, and safe eviction ledger.rs -- bounded snapshot-pair undo/redo entries workspace.rs -- `FontWorkspace` orchestration and workspace errors @@ -44,7 +45,7 @@ crates/shift-workspace/examples/ `FontWorkspace::create(source_path, store_path, options)` creates a placeholder `.shift` package, opens the working SQLite store, writes initial font metadata, and starts with an empty `shift-font::Font`. -`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. TTF/OTF, UFO, and Designspace paths use a metadata/directory-first backend cursor, parse batches of at most 512 glyphs and 1,024 authored layers, parallel-pack/hash/compress those layers, and insert them into a disposable import connection. After the complete stream commits, `finish_import` syncs the database and restores edit-time WAL settings. The returned workspace contains directory placeholders and zero loaded layer payloads. This synchronous API still returns only after finalization; publishing the directory and binary packed-outline grid while import continues requires the separate app import-session boundary. Other supported foreign formats retain the eager compatibility path until they gain a bounded reader. +`FontWorkspace::open(path, store_path)` detects `.shift` paths as source packages. TTF/OTF, UFO, and Designspace paths use a metadata/directory-first backend cursor, parse batches of at most 512 glyphs and 1,024 authored layers, parallel-pack/hash/compress those layers, and insert them into a disposable sibling staging database. The legal import transitions are **Staging** (foreign source remains authoritative), **Durable** (stream committed, indexes restored, workspace state written, database synced), then **Published** (closed staging file atomically installed and parent directory synced). Failure before Published removes staging and leaves the previous destination untouched. The returned workspace contains directory placeholders and zero loaded layer payloads. This synchronous API still returns only after finalization; publishing the directory and binary packed-outline grid while import continues requires the separate app import-session boundary. Other supported foreign formats retain the eager compatibility path until they gain a bounded reader. `FontWorkspace::save()` succeeds for saved `.shift` workspaces and returns `NeedsSaveAs` for imported workspaces. `save_as(path)` creates a `.shift` package and makes it the save target. @@ -52,7 +53,7 @@ crates/shift-workspace/examples/ `FontWorkspace::inspect_package_draft(store_path)` reads the working-store package ownership record without resuming it. It returns the package id, source path, base fingerprint, document id, and dirty flag so the utility process can choose an explicit open transition. -`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition reads directory facts, payloads, and component indexes in ordered batches of at most 512 layers and 256 MiB of decoded bytes, decompresses and verifies exact lengths plus BLAKE3 in parallel, accumulates the canonical results, and validates the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. +`FontWorkspace::resume(store_path)` builds the eager directory skeleton without reading any layer BLOB. `acquire_glyphs(ids, AcquireScope::Glyphs)` fetches only requested layers; `AcquireScope::ComponentClosure` first expands component dependencies from the relational index. Acquisition passes the complete request to the store's shared count- and decoded-byte-aware planner. Each internal batch reads directory facts, payloads, and component indexes for at most 512 layers and 256 MiB decoded bytes, decompresses and verifies exact lengths plus BLAKE3 in parallel, accumulates the canonical results, and validates the complete replacement before mutating the uniquely owned live font in place. Validated identity sets become the final index entries rather than a temporary duplicate; shared font snapshots still use copy-on-write. A malformed batch does not replace the live cache. Save/export explicitly acquire all layers before creating their complete snapshots. ## Profiling diff --git a/crates/shift-workspace/src/import_staging.rs b/crates/shift-workspace/src/import_staging.rs new file mode 100644 index 00000000..e59c6294 --- /dev/null +++ b/crates/shift-workspace/src/import_staging.rs @@ -0,0 +1,55 @@ +use std::{io, path::Path}; + +use tempfile::TempPath; + +pub(crate) fn create_import_staging_path(destination: &Path) -> io::Result { + let parent = match destination.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + }; + let staged = tempfile::Builder::new() + .prefix(".shift-import-") + .suffix(".sqlite") + .tempfile_in(parent)?; + Ok(staged.into_temp_path()) +} + +pub(crate) fn install_import_store(staged: TempPath, destination: &Path) -> io::Result<()> { + staged.persist(destination).map_err(|error| error.error)?; + sync_parent_directory(destination) +} + +#[cfg(unix)] +pub(crate) fn sync_parent_directory(path: &Path) -> io::Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::File::open(parent)?.sync_all()?; + } + Ok(()) +} + +#[cfg(not(unix))] +pub(crate) fn sync_parent_directory(_path: &Path) -> io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn staged_install_replaces_destination_and_cleans_staging_file() { + let temp = tempfile::tempdir().unwrap(); + let destination = temp.path().join("working.sqlite"); + std::fs::write(&destination, b"old").unwrap(); + let staged = create_import_staging_path(&destination).unwrap(); + let staged_path = staged.to_path_buf(); + std::fs::write(&staged_path, b"new").unwrap(); + + install_import_store(staged, &destination).unwrap(); + + assert_eq!(std::fs::read(&destination).unwrap(), b"new"); + assert!(!staged_path.exists()); + } +} diff --git a/crates/shift-workspace/src/lib.rs b/crates/shift-workspace/src/lib.rs index 5704baae..b835dcfb 100644 --- a/crates/shift-workspace/src/lib.rs +++ b/crates/shift-workspace/src/lib.rs @@ -1,4 +1,5 @@ mod import_pipeline; +mod import_staging; mod layer_residency; mod ledger; mod new_workspace; diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index aaa7a2cc..ab2e7494 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -13,11 +13,9 @@ use shift_font::{ SourceId, TouchedLayer, error::CoreError, }; use shift_source::ShiftSourcePackage; -use shift_store::{ - MAX_LAYER_READ_BATCH_COUNT, ShiftStore, SourceIdentitySnapshot, WorkspaceSourceKind, - WorkspaceState, -}; +use shift_store::{ShiftStore, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState}; +use crate::import_staging::{create_import_staging_path, install_import_store}; use crate::layer_residency::LayerResidency; use crate::ledger::{GlyphIdentity, LayerPair, Ledger, LedgerEntry, LedgerStep}; use crate::source_identity::{ @@ -749,17 +747,32 @@ impl FontWorkspace { let loader = FontLoader::new(); match loader.stream_font(import_path_str) { Ok(import) => { - let mut store = ShiftStore::open_for_import(store_path)?; + let store_path = store_path.as_ref(); + if store_path.exists() { + let existing = ShiftStore::open(store_path)?; + if existing.workspace_state()?.is_some() { + return Err(shift_store::StoreError::ImportDestinationNotEmpty( + store_path.to_path_buf(), + ) + .into()); + } + } + + let staged_path = create_import_staging_path(store_path)?; + let mut store = ShiftStore::open_for_import(&staged_path)?; let mut writer = store.font_stream_writer(import.header())?; stream_into(import, &mut writer, ImportBatchLimit::default(), |_| {})?; writer.finish()?; - store.finish_import()?; store.set_workspace_state(WorkspaceState::imported(import_path, None))?; + store.finish_import()?; let font = store.load_font_directory()?; let residency = LayerResidency::with_unloaded( font.glyphs() .flat_map(|glyph| glyph.layers().keys().cloned()), ); + drop(store); + install_import_store(staged_path, store_path)?; + let store = ShiftStore::open(store_path)?; return Ok(Self::from_store( font, @@ -839,10 +852,7 @@ impl FontWorkspace { // Decode through bounded SQLite reads. Font validates the complete // replacement batch before mutating, so malformed input leaves the // live cache unchanged without copying the complete directory. - let mut layers = Vec::with_capacity(layer_ids.len()); - for layer_ids in layer_ids.chunks(MAX_LAYER_READ_BATCH_COUNT) { - layers.extend(self.store.load_glyph_layers(layer_ids)?); - } + let layers = self.store.load_glyph_layers(&layer_ids)?; self.font.replace_glyph_layers(layers)?; self.residency.mark_loaded(layer_ids); Ok(()) @@ -1146,11 +1156,7 @@ fn replay_glyph( for layer in glyph.layers().values() { let layer = layer.as_ref().clone(); - changes.push(FontChange::glyph_layer_created( - glyph.id(), - Some(glyph.glyph_name().clone()), - &layer, - )); + changes.push(FontChange::glyph_layer_created(glyph.id(), &layer)); touched.push(TouchedLayer { layer, structural: true, @@ -1319,14 +1325,7 @@ fn replay_glyph_layer( } if let Some(layer) = to { - let glyph_name = font - .glyph(glyph_id.clone()) - .map(|glyph| glyph.glyph_name().clone()); - changes.push(FontChange::glyph_layer_created( - glyph_id.clone(), - glyph_name, - &layer, - )); + changes.push(FontChange::glyph_layer_created(glyph_id.clone(), &layer)); font.insert_glyph_layer(glyph_id, layer.clone())?; touched.push(TouchedLayer { layer, diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 09cd622d..5b8e4da5 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -1,9 +1,9 @@ use std::{fs, path::PathBuf}; use shift_font::{ - AnchorId, AnchorSeed, Axis, AxisId, BooleanOp, ContourId, FontChange, FontIntent, - FontIntentSet, GlyphId, GlyphName, LayerId, Location, NamedInstance, NamedInstanceId, PointId, - PointSeed, PointType, SourceId, error::CoreError, + AnchorId, AnchorSeed, Axis, AxisId, BooleanOp, ContourId, Font, FontChange, FontIntent, + FontIntentSet, Glyph, GlyphId, GlyphLayer, GlyphName, LayerId, Location, NamedInstance, + NamedInstanceId, PointId, PointSeed, PointType, SourceId, error::CoreError, }; use shift_source::ShiftSourcePackage; use shift_workspace::{AcquireScope, FontWorkspace, NewWorkspace, WorkspaceError, WorkspaceSource}; @@ -145,6 +145,13 @@ fn imports_external_fonts_without_a_save_target() { .family_name .is_some() ); + assert!(fs::read_dir(temp.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".shift-import-") + })); } #[test] @@ -170,9 +177,10 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { .font() .glyphs() .filter(|glyph| !glyph.layers().is_empty()) - .take(8) + .take(520) .map(|glyph| glyph.id()) .collect::>(); + assert!(requested.len() > 512); assert_eq!(workspace.loaded_layer_count(), 0); workspace .acquire_glyphs(&requested, AcquireScope::Glyphs) @@ -407,6 +415,125 @@ fn ufo_source_roundtrips_through_lazy_acquisition() { } } +#[test] +fn failed_streaming_import_removes_staging_and_preserves_the_destination() { + let temp = tempfile::tempdir().unwrap(); + let source_path = temp.path().join("Broken.ufo"); + let store_path = temp.path().join("existing.sqlite"); + let mut font = Font::new(); + let mut glyph = Glyph::new("A"); + let mut layer = GlyphLayer::new(LayerId::new(), font.default_source_id().unwrap()); + let mut contour = shift_font::Contour::new(); + contour.add_point(0.0, 0.0, PointType::OnCurve, false); + layer.add_contour(contour); + glyph.set_layer(layer); + font.insert_glyph(glyph).unwrap(); + shift_backends::font_loader::FontLoader::new() + .write_font(&font, source_path.to_str().unwrap()) + .unwrap(); + let glif_path = fs::read_dir(source_path.join("glyphs")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .find(|path| { + path.extension() + .is_some_and(|extension| extension == "glif") + }) + .unwrap(); + fs::remove_file(glif_path).unwrap(); + drop(shift_store::ShiftStore::open(&store_path).unwrap()); + let destination_before = fs::read(&store_path).unwrap(); + + assert!(FontWorkspace::open(&source_path, &store_path).is_err()); + + assert_eq!(fs::read(&store_path).unwrap(), destination_before); + assert!( + shift_store::ShiftStore::open(&store_path) + .unwrap() + .workspace_state() + .unwrap() + .is_none() + ); + assert!(fs::read_dir(temp.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".shift-import-") + })); +} + +#[test] +fn foreign_import_refuses_to_replace_a_published_workspace_store() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); + let store_path = temp.path().join("published.sqlite"); + let workspace = FontWorkspace::create_untitled(&store_path, NewWorkspace::new()).unwrap(); + drop(workspace); + + let error = match FontWorkspace::open(&source_path, &store_path) { + Ok(_) => panic!("foreign import must not replace a published workspace"), + Err(error) => error, + }; + + assert!(matches!( + error, + WorkspaceError::Store(shift_store::StoreError::ImportDestinationNotEmpty(path)) + if path == store_path + )); + let resumed = FontWorkspace::resume(&store_path).unwrap(); + assert_eq!(resumed.font().glyph_count(), 0); +} + +#[test] +fn failed_acquisition_keeps_placeholders_and_allows_an_unrelated_retry() { + let temp = tempfile::tempdir().unwrap(); + let source_path = fixture("fixtures/fonts/mutatorsans/MutatorSansLightCondensed.ufo"); + let store_path = temp.path().join("corrupt-acquisition.sqlite"); + let mut workspace = FontWorkspace::open(&source_path, &store_path).unwrap(); + let requested = workspace + .font() + .glyphs() + .filter(|glyph| !glyph.layers().is_empty()) + .take(2) + .map(|glyph| glyph.id()) + .collect::>(); + assert_eq!(requested.len(), 2); + let corrupt_layer_id = workspace + .font() + .glyph(requested[0].clone()) + .unwrap() + .layers() + .keys() + .next() + .unwrap() + .clone(); + let conn = rusqlite::Connection::open(&store_path).unwrap(); + conn.execute( + "UPDATE glyph_layer_payloads + SET payload = X'00', stored_byte_length = 1 + WHERE layer_id = ?1", + [corrupt_layer_id.to_string()], + ) + .unwrap(); + drop(conn); + + let error = workspace + .acquire_glyphs(std::slice::from_ref(&requested[0]), AcquireScope::Glyphs) + .unwrap_err(); + + assert!(matches!( + error, + WorkspaceError::Store(shift_store::StoreError::LayerDecompression(_)) + )); + assert_eq!(workspace.loaded_layer_count(), 0); + assert!(workspace.font().layer(corrupt_layer_id).unwrap().is_empty()); + + workspace + .acquire_glyphs(std::slice::from_ref(&requested[1]), AcquireScope::Glyphs) + .unwrap(); + assert!(workspace.loaded_layer_count() > 0); +} + #[test] fn save_requires_save_as_for_untitled_workspaces() { let temp = tempfile::tempdir().unwrap(); From 23bc249871bf65275c1a709cba26cfd0fb575ebd Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 30 Jul 2026 11:02:26 +0000 Subject: [PATCH 23/29] Speed up values-only layer replay --- .../renderer/src/perf/workspaceApply.bench.ts | 96 ++++++++++++++++- crates/shift-bridge/src/bridge.rs | 47 +++++++- crates/shift-font/src/intents.rs | 11 +- crates/shift-font/src/ir/font.rs | 102 +++++++++++++++++- crates/shift-font/src/ir/glyph.rs | 8 +- crates/shift-workspace/docs/DOCS.md | 1 + crates/shift-workspace/src/ledger.rs | 9 +- crates/shift-workspace/src/workspace.rs | 60 ++++++----- .../shift-workspace/tests/workspace_test.rs | 40 +++++-- 9 files changed, 326 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts index ef03861b..3986478e 100644 --- a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts +++ b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts @@ -1,4 +1,4 @@ -import { bench, describe } from "vitest"; +import { afterAll, bench, describe } from "vitest"; import type { GlyphName, PointType, Unicode } from "@shift/types"; import { mintContourId, mintGlyphId, mintLayerId, mintPointId } from "@shift/types"; import { createWorkspaceStack } from "@/testing/workspaceStack"; @@ -60,6 +60,74 @@ function squareIntents(width: number) { // Seed one contour so undo/redo and pulls always have real geometry. await stack.editCoordinator.apply([...squareIntents(100)]); +const LARGE_POINT_COUNT = 50_000; +const largeStack = createWorkspaceStack(); +await largeStack.createWorkspace(); +const largeGlyphId = mintGlyphId(); +const largeLayerId = mintLayerId(); +const largeContourId = mintContourId(); +const largePointIds = Array.from({ length: LARGE_POINT_COUNT }, () => mintPointId()); +const largePoints = largePointIds.map((id, index) => ({ + id, + x: index % 500, + y: Math.floor(index / 500), + pointType: "onCurve" as PointType, + smooth: false, +})); +const largeCreated = await largeStack.editCoordinator.apply([ + { + kind: "createGlyph", + createGlyph: { glyphId: largeGlyphId, name: "large" as GlyphName, unicodes: [] }, + }, + { + kind: "createGlyphLayer", + createGlyphLayer: { + layerId: largeLayerId, + glyphId: largeGlyphId, + sourceId: largeStack.font.defaultSource.id, + }, + }, + { + kind: "addContour", + addContour: { layerId: largeLayerId, contourId: largeContourId, closed: true }, + }, + { + kind: "addPoints", + addPoints: { + layerId: largeLayerId, + contourId: largeContourId, + points: largePoints, + }, + }, +]); +if (largeCreated.layers[0]?.structure?.contours[0]?.points.length !== LARGE_POINT_COUNT) { + throw new Error("50K benchmark setup did not create the complete contour"); +} +await largeStack.editCoordinator.apply([ + { + kind: "movePoints", + movePoints: { + layerId: largeLayerId, + pointIds: largePointIds, + coords: largePoints.flatMap((point) => [point.x + 1, point.y - 1]), + }, + }, +]); + +const largeUndoSamples: number[] = []; +const largeRedoSamples: number[] = []; + +function percentile(samples: readonly number[], fraction: number): number { + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length * fraction)] ?? 0; +} + +afterAll(() => { + console.log( + `50K position replay: undo p50=${percentile(largeUndoSamples, 0.5).toFixed(2)}ms p95=${percentile(largeUndoSamples, 0.95).toFixed(2)}ms; redo p50=${percentile(largeRedoSamples, 0.5).toFixed(2)}ms p95=${percentile(largeRedoSamples, 0.95).toFixed(2)}ms`, + ); +}); + describe("workspace apply round trip (channel + NAPI + SQLite)", () => { let width = 0; @@ -83,4 +151,30 @@ describe("workspace apply round trip (channel + NAPI + SQLite)", () => { bench("replace-grade glyph state pull", async () => { await stack.editCoordinator.readGlyphSnapshots([{ glyphId }]); }); + + bench( + "50K point position-only undo + redo", + async () => { + let started = performance.now(); + const undone = await largeStack.editCoordinator.undo(); + largeUndoSamples.push(performance.now() - started); + if (!undone || undone.layers.length !== 1) { + throw new Error("50K position edit did not undo exactly one layer"); + } + if (undone.layers[0].structure) { + throw new Error("50K position undo crossed the bridge with structure"); + } + + started = performance.now(); + const redone = await largeStack.editCoordinator.redo(); + largeRedoSamples.push(performance.now() - started); + if (!redone || redone.layers.length !== 1) { + throw new Error("50K position edit did not redo exactly one layer"); + } + if (redone.layers[0].structure) { + throw new Error("50K position redo crossed the bridge with structure"); + } + }, + { iterations: 10, time: 0, warmupIterations: 0, warmupTime: 0 }, + ); }); diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index eba7692d..bb1c881b 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -643,8 +643,8 @@ impl Bridge { layer_id: touched.layer.id().to_string(), structure: touched .structural - .then(|| GlyphStructure::from(&touched.layer).into()), - values: shift_wire::values_from_layer(&touched.layer).into(), + .then(|| GlyphStructure::from(touched.layer.as_ref()).into()), + values: shift_wire::values_from_layer(touched.layer.as_ref()).into(), changed: GlyphChangedEntities::default().into(), }) .collect(); @@ -2342,13 +2342,56 @@ mod tests { let undone = bridge.undo().unwrap().expect("one entry to undo"); assert_eq!(undone.layers.len(), 1); + assert!(undone.layers[0].structure.is_some()); assert_eq!(contour_point_count(&mut bridge), 0); let redone = bridge.redo().unwrap().expect("one entry to redo"); assert_eq!(redone.layers.len(), 1); + assert!(redone.layers[0].structure.is_some()); assert_eq!(contour_point_count(&mut bridge), 1); } + #[test] + fn position_only_undo_redo_echoes_values_without_structure() { + let mut bridge = bridge_with_workspace(); + let (layer_id, contour_id) = pen_setup(&mut bridge); + let point_id = shift_font::PointId::new().to_string(); + bridge + .apply( + vec![add_points_intent( + &layer_id, + &contour_id, + None, + vec![seed(&point_id, 10.0, 20.0)], + )], + None, + ) + .unwrap(); + bridge + .apply( + vec![NapiFontIntent { + move_points: Some(NapiMovePointsIntent { + layer_id, + point_ids: vec![point_id], + coords: vec![30.0, 40.0], + }), + ..skeleton_intent("movePoints") + }], + None, + ) + .unwrap(); + + let undone = bridge.undo().unwrap().expect("position edit should undo"); + assert!(undone.layers[0].structure.is_none()); + assert_eq!(&undone.layers[0].values[1..3], &[10.0, 20.0]); + assert_eq!(&glyph_state(&mut bridge, "A").values[1..3], &[10.0, 20.0]); + + let redone = bridge.redo().unwrap().expect("position edit should redo"); + assert!(redone.layers[0].structure.is_none()); + assert_eq!(&redone.layers[0].values[1..3], &[30.0, 40.0]); + assert_eq!(&glyph_state(&mut bridge, "A").values[1..3], &[30.0, 40.0]); + } + #[test] fn undo_returns_none_when_the_ledger_is_empty() { let mut bridge = bridge_with_workspace(); diff --git a/crates/shift-font/src/intents.rs b/crates/shift-font/src/intents.rs index 138b2302..e2184570 100644 --- a/crates/shift-font/src/intents.rs +++ b/crates/shift-font/src/intents.rs @@ -17,6 +17,7 @@ use crate::ir::{ use crate::layer_edit::BulkNodePositionUpdates; use crate::source::source_locations_equal; use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; /// A point to create, with stable identity minted by a trusted caller. #[derive(Clone, Debug)] @@ -311,7 +312,7 @@ pub struct FontIntentSet { /// One touched layer after an intent set applied. pub struct TouchedLayer { - pub layer: GlyphLayer, + pub layer: Arc, pub structural: bool, } @@ -366,9 +367,11 @@ impl Font { .into_iter() .map(|(layer_id, structural)| { let layer = self - .layer(layer_id.clone()) - .ok_or(CoreError::LayerNotFound(layer_id))? - .clone(); + .glyph_id_by_layer(layer_id.clone()) + .and_then(|glyph_id| self.glyph(glyph_id)) + .and_then(|glyph| glyph.layers().get(&layer_id)) + .cloned() + .ok_or(CoreError::LayerNotFound(layer_id))?; Ok(TouchedLayer { layer, structural }) }) .collect::>>()?; diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index d4236de3..a53c8553 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -9,6 +9,7 @@ use crate::error::{CoreError, CoreResult}; use crate::features::FeatureData; use crate::glyph::{Glyph, GlyphLayer}; use crate::guideline::Guideline; +use crate::interpolation::GlyphInterpolationValues; use crate::kerning::KerningData; use crate::lib_data::LibData; use crate::metrics::{FontMetrics, MetricDefinition, MetricKind, MetricValue}; @@ -286,7 +287,7 @@ impl FontIndex { fn validate_layer_replacements( &self, - replacements: &[(GlyphId, Arc, GlyphLayer)], + replacements: &[(GlyphId, Arc, Arc)], ) -> CoreResult { let mut removed_contour_ids = HashSet::new(); let mut removed_point_ids = HashSet::new(); @@ -1191,9 +1192,37 @@ impl Font { Ok(()) } + /// Replaces one layer's canonical numeric values without changing its structure or indexes. + /// + /// Value count and finiteness are validated before the layer changes. + pub fn replace_glyph_layer_values( + &mut self, + layer_id: LayerId, + values: &GlyphInterpolationValues, + ) -> CoreResult<()> { + let glyph_id = self + .glyph_id_by_layer(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id.clone()))?; + let state = self.state_mut(); + let glyph = state + .data + .glyphs + .get_mut(&glyph_id) + .expect("layer owner was resolved before mutation"); + let layer = Arc::make_mut(glyph) + .layer_mut(layer_id.clone()) + .ok_or(CoreError::LayerNotFound(layer_id))?; + + layer.apply_interpolation_values(values) + } + /// Atomically replaces existing layers after validating the complete batch. /// Uniquely owned fonts mutate in place; shared snapshots retain copy-on-write semantics. - pub fn replace_glyph_layers(&mut self, layers: Vec) -> CoreResult<()> { + /// Already shared layer snapshots transfer without cloning their geometry. + pub fn replace_glyph_layers(&mut self, layers: Vec) -> CoreResult<()> + where + L: Into>, + { if layers.is_empty() { return Ok(()); } @@ -1201,6 +1230,7 @@ impl Font { let mut seen_layer_ids = HashSet::with_capacity(layers.len()); let mut replacements = Vec::with_capacity(layers.len()); for layer in layers { + let layer = layer.into(); let layer_id = layer.id(); if !seen_layer_ids.insert(layer_id.clone()) { return Err(CoreError::DuplicateLayerId(layer_id)); @@ -1856,6 +1886,74 @@ mod tests { assert_eq!(font.layer(second_layer_id).unwrap().width(), 800.0); } + #[test] + fn glyph_layer_value_replacement_preserves_structure_indexes_and_snapshots() { + let mut font = Font::new(); + let source_id = font.default_source_id().unwrap(); + let contour_id = ContourId::new(); + let point_id = PointId::new(); + let anchor_id = AnchorId::new(); + let component_id = ComponentId::new(); + let mut contour = Contour::with_id(contour_id.clone()); + contour.add_point_with_id(point_id.clone(), 10.0, 20.0, PointType::OnCurve, false); + let mut layer = GlyphLayer::with_width(LayerId::new(), source_id, 500.0); + layer.add_contour(contour); + layer.add_anchor(Anchor::with_id( + anchor_id.clone(), + Some("top".to_string()), + 30.0, + 40.0, + )); + layer.add_component(Component::with_id( + component_id.clone(), + GlyphId::new(), + "base", + crate::DecomposedTransform::default(), + )); + let layer_id = layer.id(); + let mut glyph = Glyph::new("A"); + glyph.set_layer(layer); + font.insert_glyph(glyph).unwrap(); + + let snapshot = font.clone(); + let mut expected = font.layer(layer_id.clone()).unwrap().clone(); + expected.set_width(700.0); + expected.contours_iter_mut().next().unwrap().points_mut()[0].set_position(50.0, 60.0); + expected + .anchors_iter_mut() + .next() + .unwrap() + .set_position(70.0, 80.0); + expected + .components_iter_mut() + .next() + .unwrap() + .translate(90.0, 100.0); + let values = expected.interpolation_values(); + + font.replace_glyph_layer_values(layer_id.clone(), &values) + .unwrap(); + + assert_eq!(font.layer(layer_id.clone()).unwrap(), &expected); + assert_ne!(snapshot.layer(layer_id.clone()).unwrap(), &expected); + assert!(font.has_contour_id(&contour_id)); + assert!(font.has_point_id(&point_id)); + assert!(font.has_anchor_id(&anchor_id)); + assert!(font.index().component_ids.contains(&component_id)); + + let committed = font.layer(layer_id.clone()).unwrap().clone(); + let mut invalid = values.into_vec(); + invalid[0] = f64::NAN; + assert!(matches!( + font.replace_glyph_layer_values( + layer_id.clone(), + &GlyphInterpolationValues::new(invalid) + ), + Err(CoreError::InvalidPositionUpdateInput { .. }) + )); + assert_eq!(font.layer(layer_id).unwrap(), &committed); + } + #[test] fn glyph_layer_batch_replacement_preserves_snapshots_and_identity_indexes() { let mut font = Font::new(); diff --git a/crates/shift-font/src/ir/glyph.rs b/crates/shift-font/src/ir/glyph.rs index 571ad873..eaec54ac 100644 --- a/crates/shift-font/src/ir/glyph.rs +++ b/crates/shift-font/src/ir/glyph.rs @@ -386,8 +386,12 @@ impl Glyph { self.layer_mut(layer_id).expect("layer was just inserted") } - pub fn set_layer(&mut self, layer: GlyphLayer) { - self.layers.insert(layer.id(), Arc::new(layer)); + pub fn set_layer(&mut self, layer: L) + where + L: Into>, + { + let layer = layer.into(); + self.layers.insert(layer.id(), layer); } pub fn layer_for_source(&self, source_id: SourceId) -> Option<&GlyphLayer> { diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 65845547..52c690c2 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -11,6 +11,7 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. - **Architecture Invariant:** The workspace is the domain object future bridge or utility-process transports should wrap. +- **Architecture Invariant:** Ledger layer pairs retain the original touched-layer structural classification. Values-only undo/redo restores the target snapshot's canonical numeric values without rebuilding identity indexes or emitting structure; structural replay installs and emits the complete target structure in both directions. - **Architecture Invariant:** Ledger replay restores complete named-instance collections after axis topology so undo/redo never observes an instance against the wrong external-axis shape. - **Architecture Invariant:** Metadata ledger entries store complete pre/post snapshots and replay them independently of font metrics. - **Architecture Invariant:** Metric-definition ledger state replays before complete source snapshots so source metric IDs are always valid during undo and redo. diff --git a/crates/shift-workspace/src/ledger.rs b/crates/shift-workspace/src/ledger.rs index 219b7922..413951c7 100644 --- a/crates/shift-workspace/src/ledger.rs +++ b/crates/shift-workspace/src/ledger.rs @@ -7,6 +7,8 @@ //! a renderer reload (it lives with the workspace process), not a utility //! crash; a SQLite ledger table is the later upgrade if that ever matters. +use std::sync::Arc; + use shift_font::{ Axis, AxisMapping, FontMetadata, Glyph, GlyphId, GlyphLayer, GlyphName, LayerId, MetricDefinition, NamedInstance, Source, SourceId, @@ -85,8 +87,11 @@ pub struct GlyphIdentity { #[derive(Clone)] pub struct LayerPair { - pub pre: GlyphLayer, - pub post: GlyphLayer, + pub pre: Arc, + pub post: Arc, + /// Fixed when the entry is created. Both replay directions preserve + /// topology when false and must publish complete structure when true. + pub structural: bool, } #[derive(Clone)] diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index ab2e7494..805ebf07 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -2,6 +2,7 @@ use std::{ collections::{HashMap, HashSet}, io, path::{Path, PathBuf}, + sync::Arc, }; use shift_backends::{ @@ -270,13 +271,14 @@ impl FontWorkspace { if pre.layers.iter().any(|pre| pre.layer.id() == *layer_id) { continue; } - if let Some(layer) = self.font.layer(layer_id.clone()) - && let Some(glyph_id) = self.font.glyph_id_by_layer(layer_id.clone()) + if let Some(glyph_id) = self.font.glyph_id_by_layer(layer_id.clone()) + && let Some(layer) = self + .font + .glyph(glyph_id.clone()) + .and_then(|glyph| glyph.layers().get(layer_id)) + .cloned() { - pre.layers.push(PreLayer { - glyph_id, - layer: layer.clone(), - }); + pre.layers.push(PreLayer { glyph_id, layer }); } } @@ -307,6 +309,7 @@ impl FontWorkspace { .map(|pre| LayerPair { pre: pre.layer.clone(), post, + structural: touched.structural, }) }) .collect(); @@ -438,7 +441,7 @@ impl FontWorkspace { .find(|pre| { pre.glyph_id == change.glyph_id && pre.layer.id() == change.layer_id }) - .map(|pre| Box::new(pre.layer.clone())), + .map(|pre| Box::new(pre.layer.as_ref().clone())), post: None, }); } @@ -965,7 +968,7 @@ impl FontWorkspace { #[derive(Clone)] struct PreLayer { glyph_id: GlyphId, - layer: GlyphLayer, + layer: Arc, } #[derive(Default)] @@ -1038,7 +1041,12 @@ fn capture_font_level_pre_state( } for glyph in font.glyphs() { - let Some(layer) = glyph.layer_for_source(source_id.clone()) else { + let Some(layer) = glyph + .layers() + .values() + .find(|layer| layer.source_id() == *source_id) + .cloned() + else { continue; }; if pre.layers.iter().any(|pre| pre.layer.id() == layer.id()) { @@ -1046,7 +1054,7 @@ fn capture_font_level_pre_state( } pre.layers.push(PreLayer { glyph_id: glyph.id(), - layer: layer.clone(), + layer, }); } } @@ -1113,25 +1121,28 @@ fn replay_layer_pairs( changes: &mut FontChangeSet, touched: &mut Vec, ) -> Result<(), WorkspaceError> { + let mut replayed = Vec::with_capacity(pairs.len()); + let mut structural_replacements = Vec::with_capacity(pairs.len()); for pair in pairs { let replacement = match side { ReplaySide::Pre => pair.pre, ReplaySide::Post => pair.post, }; - let layer_id = replacement.id(); - let layer = font - .layer_mut(layer_id.clone()) - .ok_or(CoreError::LayerNotFound(layer_id))?; - *layer = replacement; + if pair.structural { + structural_replacements.push(replacement.clone()); + } else { + font.replace_glyph_layer_values(replacement.id(), &replacement.interpolation_values())?; + } + replayed.push((replacement, pair.structural)); + } + font.replace_glyph_layers(structural_replacements)?; + for (layer, structural) in replayed { // Geometry replace persists contours only; metrics ride their // own change so width/height restores reach SQLite too. - changes.push(FontChange::layer_geometry_replaced(layer)); - changes.push(FontChange::layer_metrics_changed(layer)); - touched.push(TouchedLayer { - layer: layer.clone(), - structural: true, - }); + changes.push(FontChange::layer_geometry_replaced(layer.as_ref())); + changes.push(FontChange::layer_metrics_changed(layer.as_ref())); + touched.push(TouchedLayer { layer, structural }); } Ok(()) @@ -1155,10 +1166,9 @@ fn replay_glyph( changes.push(FontChange::glyph_created(&glyph)); for layer in glyph.layers().values() { - let layer = layer.as_ref().clone(); - changes.push(FontChange::glyph_layer_created(glyph.id(), &layer)); + changes.push(FontChange::glyph_layer_created(glyph.id(), layer.as_ref())); touched.push(TouchedLayer { - layer, + layer: layer.clone(), structural: true, }); } @@ -1328,7 +1338,7 @@ fn replay_glyph_layer( changes.push(FontChange::glyph_layer_created(glyph_id.clone(), &layer)); font.insert_glyph_layer(glyph_id, layer.clone())?; touched.push(TouchedLayer { - layer, + layer: Arc::new(layer), structural: true, }); } diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index 5b8e4da5..f420d822 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -1394,19 +1394,39 @@ fn set_contour_closed_undo_redo_restores_layer() { } #[test] -fn move_points_undo_redo_restores_layer() { +fn move_points_undo_redo_restores_exact_coordinates_without_structure() { let (_temp, mut workspace, layer_id) = workspace_with_layer(); let (_, point_ids) = add_square_contour(&mut workspace, &layer_id, (0.0, 0.0), 100.0); + let pre = workspace.font().layer(layer_id.clone()).unwrap().clone(); - assert_layer_undo_redo( - &mut workspace, - &layer_id, - vec![FontIntent::MovePoints { - layer_id: layer_id.clone(), - point_ids: vec![point_ids[0].clone(), point_ids[1].clone()], - coords: vec![5.0, 6.0, 105.0, 6.0], - }], - ); + let applied = workspace + .apply( + FontIntentSet { + intents: vec![FontIntent::MovePoints { + layer_id: layer_id.clone(), + point_ids: vec![point_ids[0].clone(), point_ids[1].clone()], + coords: vec![5.0, 6.0, 105.0, 6.0], + }], + }, + None, + ) + .unwrap(); + let post = workspace.font().layer(layer_id.clone()).unwrap().clone(); + assert!(!applied.layers[0].structural); + + let undone = workspace + .undo() + .unwrap() + .expect("position edit should undo"); + assert!(!undone.layers[0].structural); + assert_eq!(workspace.font().layer(layer_id.clone()).unwrap(), &pre); + + let redone = workspace + .redo() + .unwrap() + .expect("position edit should redo"); + assert!(!redone.layers[0].structural); + assert_eq!(workspace.font().layer(layer_id).unwrap(), &post); } #[test] From dbcc9b65de5903ce71522e03b913b6c84e3973ef Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 31 Jul 2026 14:59:03 -0400 Subject: [PATCH 24/29] Replace custom glyph codecs with MessagePack --- Cargo.lock | 105 +- Cargo.toml | 1 - crates/shift-backends/src/ufo/mod.rs | 2 +- crates/shift-backends/src/ufo/reader.rs | 6 +- crates/shift-font/Cargo.toml | 1 - crates/shift-font/docs/DOCS.md | 8 +- crates/shift-font/src/codec_adapter.rs | 331 --- crates/shift-font/src/ir/entity.rs | 54 +- crates/shift-font/src/ir/font.rs | 288 +-- crates/shift-font/src/ir/glyph_name.rs | 15 +- crates/shift-font/src/ir/lib_data.rs | 21 +- crates/shift-font/src/ir/mod.rs | 4 +- crates/shift-font/src/lib.rs | 5 - crates/shift-font/src/test_support.rs | 6 +- crates/shift-glyph-codec/Cargo.toml | 14 - crates/shift-glyph-codec/SPECIFICATION.md | 336 --- .../examples/layer_benchmark.rs | 136 -- .../examples/outline_benchmark.rs | 60 - crates/shift-glyph-codec/src/frame.rs | 47 - crates/shift-glyph-codec/src/layer.rs | 1935 ----------------- crates/shift-glyph-codec/src/lib.rs | 24 - crates/shift-glyph-codec/src/outline.rs | 664 ------ crates/shift-glyph-codec/tests/fixtures.rs | 146 -- crates/shift-glyph-codec/tests/layer.rs | 425 ---- crates/shift-glyph-codec/tests/outline.rs | 386 ---- crates/shift-slug/Cargo.toml | 1 - crates/shift-slug/docs/DOCS.md | 2 + crates/shift-slug/examples/analyze_font.rs | 3 +- .../examples/benchmark_variable_wgpu.rs | 5 +- crates/shift-slug/examples/benchmark_wgpu.rs | 3 +- crates/shift-slug/src/atlas.rs | 5 +- crates/shift-slug/src/curve.rs | 4 +- crates/shift-slug/src/lib.rs | 2 + crates/shift-slug/src/outline.rs | 27 + crates/shift-slug/src/variable.rs | 4 +- crates/shift-slug/tests/atlas.rs | 2 +- crates/shift-slug/tests/variable_model.rs | 3 +- crates/shift-source/src/package.rs | 2 +- crates/shift-store/Cargo.toml | 2 + crates/shift-store/README.md | 17 +- crates/shift-store/src/error.rs | 10 +- crates/shift-store/src/font_state.rs | 4 +- crates/shift-store/src/lib.rs | 4 +- crates/shift-store/src/packed_layer.rs | 92 +- crates/shift-store/src/stored_layer.rs | 346 ++- crates/shift-store/src/stream_writer.rs | 59 +- crates/shift-store/src/types.rs | 24 +- crates/shift-store/tests/store_test.rs | 2 +- crates/shift-workspace/docs/DOCS.md | 6 +- .../examples/profile_streaming_import.rs | 2 +- crates/shift-workspace/src/import_pipeline.rs | 8 +- crates/shift-workspace/src/import_staging.rs | 30 +- crates/shift-workspace/src/workspace.rs | 2 +- .../shift-workspace/tests/workspace_test.rs | 65 +- docs/architecture/index.md | 2 - fixtures/glyph-codec/layer-v1/empty.bin | Bin 113 -> 0 bytes fixtures/glyph-codec/layer-v1/full.bin | Bin 945 -> 0 bytes .../glyph-codec/layer-v1/guideline-flags.bin | Bin 584 -> 0 bytes fixtures/glyph-codec/layer-v1/lib-only.bin | Bin 223 -> 0 bytes fixtures/glyph-codec/layer-v1/vectors.json | 29 - .../glyph-codec/outline-v1/closed-line.bin | Bin 36 -> 0 bytes fixtures/glyph-codec/outline-v1/empty.bin | Bin 16 -> 0 bytes .../outline-v1/multiple-contours.bin | Bin 56 -> 0 bytes fixtures/glyph-codec/outline-v1/open.bin | Bin 36 -> 0 bytes .../outline-v1/quadratic-cubic.bin | Bin 68 -> 0 bytes fixtures/glyph-codec/outline-v1/vectors.json | 100 - packages/glyph-codec/README.md | 52 - packages/glyph-codec/package.json | 25 - packages/glyph-codec/src/error.ts | 15 - packages/glyph-codec/src/fixtures.test.ts | 107 - packages/glyph-codec/src/frame.ts | 50 - packages/glyph-codec/src/index.ts | 36 - packages/glyph-codec/src/layer-binary.ts | 143 -- packages/glyph-codec/src/layer-lib.ts | 256 --- packages/glyph-codec/src/layer-limits.ts | 31 - packages/glyph-codec/src/layer-strings.ts | 148 -- packages/glyph-codec/src/layer.test.ts | 309 --- packages/glyph-codec/src/layer.ts | 721 ------ packages/glyph-codec/src/outline.test.ts | 275 --- packages/glyph-codec/src/outline.ts | 374 ---- packages/glyph-codec/src/types.ts | 133 -- packages/glyph-codec/tsconfig.json | 9 - packages/glyph-codec/vitest.config.ts | 8 - pnpm-lock.yaml | 14 +- 84 files changed, 765 insertions(+), 7828 deletions(-) delete mode 100644 crates/shift-font/src/codec_adapter.rs delete mode 100644 crates/shift-glyph-codec/Cargo.toml delete mode 100644 crates/shift-glyph-codec/SPECIFICATION.md delete mode 100644 crates/shift-glyph-codec/examples/layer_benchmark.rs delete mode 100644 crates/shift-glyph-codec/examples/outline_benchmark.rs delete mode 100644 crates/shift-glyph-codec/src/frame.rs delete mode 100644 crates/shift-glyph-codec/src/layer.rs delete mode 100644 crates/shift-glyph-codec/src/lib.rs delete mode 100644 crates/shift-glyph-codec/src/outline.rs delete mode 100644 crates/shift-glyph-codec/tests/fixtures.rs delete mode 100644 crates/shift-glyph-codec/tests/layer.rs delete mode 100644 crates/shift-glyph-codec/tests/outline.rs create mode 100644 crates/shift-slug/src/outline.rs delete mode 100644 fixtures/glyph-codec/layer-v1/empty.bin delete mode 100644 fixtures/glyph-codec/layer-v1/full.bin delete mode 100644 fixtures/glyph-codec/layer-v1/guideline-flags.bin delete mode 100644 fixtures/glyph-codec/layer-v1/lib-only.bin delete mode 100644 fixtures/glyph-codec/layer-v1/vectors.json delete mode 100644 fixtures/glyph-codec/outline-v1/closed-line.bin delete mode 100644 fixtures/glyph-codec/outline-v1/empty.bin delete mode 100644 fixtures/glyph-codec/outline-v1/multiple-contours.bin delete mode 100644 fixtures/glyph-codec/outline-v1/open.bin delete mode 100644 fixtures/glyph-codec/outline-v1/quadratic-cubic.bin delete mode 100644 fixtures/glyph-codec/outline-v1/vectors.json delete mode 100644 packages/glyph-codec/README.md delete mode 100644 packages/glyph-codec/package.json delete mode 100644 packages/glyph-codec/src/error.ts delete mode 100644 packages/glyph-codec/src/fixtures.test.ts delete mode 100644 packages/glyph-codec/src/frame.ts delete mode 100644 packages/glyph-codec/src/index.ts delete mode 100644 packages/glyph-codec/src/layer-binary.ts delete mode 100644 packages/glyph-codec/src/layer-lib.ts delete mode 100644 packages/glyph-codec/src/layer-limits.ts delete mode 100644 packages/glyph-codec/src/layer-strings.ts delete mode 100644 packages/glyph-codec/src/layer.test.ts delete mode 100644 packages/glyph-codec/src/layer.ts delete mode 100644 packages/glyph-codec/src/outline.test.ts delete mode 100644 packages/glyph-codec/src/outline.ts delete mode 100644 packages/glyph-codec/src/types.ts delete mode 100644 packages/glyph-codec/tsconfig.json delete mode 100644 packages/glyph-codec/vitest.config.ts diff --git a/Cargo.lock b/Cargo.lock index 39d700e3..a8f5a156 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,16 +239,7 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures 0.3.0", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", + "cpufeatures", ] [[package]] @@ -469,15 +460,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -559,16 +541,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "ctor" version = "0.11.1" @@ -661,16 +633,6 @@ dependencies = [ "syn", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "dispatch2" version = "0.3.1" @@ -1043,16 +1005,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.3.3" @@ -2132,6 +2084,25 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -2279,17 +2250,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - [[package]] name = "shift-backends" version = "0.1.0" @@ -2361,19 +2321,9 @@ dependencies = [ "linesweeper", "serde", "serde_json", - "shift-glyph-codec", "thiserror", ] -[[package]] -name = "shift-glyph-codec" -version = "0.1.0" -dependencies = [ - "serde", - "serde_json", - "sha2", -] - [[package]] name = "shift-slug" version = "0.1.0" @@ -2382,7 +2332,6 @@ dependencies = [ "pollster", "shift-backends", "shift-font", - "shift-glyph-codec", "skrifa", "tiny-skia", "wgpu", @@ -2407,7 +2356,9 @@ version = "0.1.0" dependencies = [ "blake3", "rayon", + "rmp-serde", "rusqlite", + "serde", "serde_json", "shift-font", "tempfile", @@ -2714,12 +2665,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "ufo2fontir" version = "0.4.0" @@ -2842,12 +2787,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 85965ef8..35d3cab0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ shift-wire = { path = "crates/shift-wire" } shift-backends = { path = "crates/shift-backends" } shift-bridge = { path = "crates/shift-bridge" } shift-font = { path = "crates/shift-font" } -shift-glyph-codec = { path = "crates/shift-glyph-codec" } shift-slug = { path = "crates/shift-slug" } shift-store = { path = "crates/shift-store" } shift-source = { path = "crates/shift-source" } diff --git a/crates/shift-backends/src/ufo/mod.rs b/crates/shift-backends/src/ufo/mod.rs index 1fc04c55..7279994c 100644 --- a/crates/shift-backends/src/ufo/mod.rs +++ b/crates/shift-backends/src/ufo/mod.rs @@ -181,7 +181,7 @@ mod tests { let mut broken = create_test_font(); broken.lib_mut().set( "public.objectLibs".to_string(), - shift_font::LibValue::Dict(std::collections::HashMap::new()), + shift_font::LibValue::Dict(std::collections::BTreeMap::new()), ); UfoWriter::new() .save(&broken, ufo_path_str) diff --git a/crates/shift-backends/src/ufo/reader.rs b/crates/shift-backends/src/ufo/reader.rs index 35864151..b7be707d 100644 --- a/crates/shift-backends/src/ufo/reader.rs +++ b/crates/shift-backends/src/ufo/reader.rs @@ -8,7 +8,7 @@ use shift_font::{ KerningPair, KerningSide, LayerId, LibData, LibValue, MetricKind, PointType, SourceId, Transform, }; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; pub struct UfoReader; @@ -130,7 +130,7 @@ impl UfoReader { LibValue::Array(arr.iter().map(Self::convert_plist_to_lib_value).collect()) } plist::Value::Dictionary(dict) => { - let mut map = HashMap::new(); + let mut map = BTreeMap::new(); for (k, v) in dict.iter() { map.insert(k.clone(), Self::convert_plist_to_lib_value(v)); } @@ -177,7 +177,7 @@ impl UfoReader { } pub(crate) fn convert_lib(lib: &plist::Dictionary) -> LibData { - let mut data = HashMap::new(); + let mut data = BTreeMap::new(); for (k, v) in lib.iter() { data.insert(k.clone(), Self::convert_plist_to_lib_value(v)); } diff --git a/crates/shift-font/Cargo.toml b/crates/shift-font/Cargo.toml index 8948cddc..43b39fb1 100644 --- a/crates/shift-font/Cargo.toml +++ b/crates/shift-font/Cargo.toml @@ -17,7 +17,6 @@ indexmap = { version = "2", features = ["serde"] } kurbo = "0.13.0" linesweeper = "0.3.0" serde = { version = "1.0", features = ["derive", "rc"] } -shift-glyph-codec = { workspace = true } thiserror = "2.0.18" getrandom = "0.3.3" diff --git a/crates/shift-font/docs/DOCS.md b/crates/shift-font/docs/DOCS.md index 4aa415b5..13cae11a 100644 --- a/crates/shift-font/docs/DOCS.md +++ b/crates/shift-font/docs/DOCS.md @@ -22,7 +22,6 @@ crates/shift-font/src/ intents.rs -- atomic authoring intents and semantic application changes.rs -- replace-grade semantic change records layer_edit.rs -- glyph-layer geometry mutations - codec_adapter.rs -- lossless shift.glyph-layer.v1 domain adapter variation.rs -- external-to-design mapping evaluation interpolation.rs -- source compatibility, reusable bases, source values projection.rs -- location-independent glyph payloads and resolved views @@ -43,7 +42,8 @@ crates/shift-font/src/ - `SourceMetricInterpolation` owns metric identity, optional technical-field participation, variation regions, and delta ordering for source-owned metrics. - `Glyph` is a glyph concept identified by `GlyphId`. - `GlyphLayer` is authored editable data for one glyph at one source. -- `pack_glyph_layer`, `unpack_glyph_layer`, and `glyph_layer_from_view` adapt one layer to/from canonical `shift.glyph-layer.v1` while validating typed IDs and glyph names at the domain boundary. +- `LibData` and recursive `LibValue::Dict` use ordered maps so equal domain values serialize deterministically. +- `GlyphEntityId` gives `FontIndex` one typed set for contour, point, component, anchor, and guideline identity. - `InterpolationBasis` is coordinate-independent variation math for an ordered source set. It contains normalized supports and source coefficient rows, never glyph coordinates or metrics. - `GlyphInterpolation` combines a reusable basis with one glyph's compatible authored source values. The glyph's default-source layer owns topology when present; otherwise a deterministic master-backed reference layer allows sparse glyph interpolation. - `LayerCompatibility` records every hard structural difference between an interpolation reference layer and another source layer. `LayerDifference` retains ordered path, node, anchor, and component evidence for diagnostics. @@ -70,7 +70,7 @@ Stable IDs are identity. Names and Unicode values are editable metadata. - Own font authoring data structures such as `Font`, `Glyph`, `GlyphLayer`, `Contour`, `Point`, `Source`, and `Axis`. - Keep object-level mutation behavior near the objects it mutates. -- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` retains previous layers through cheap `Arc` references, validates a complete hydration/eviction batch into one `ReplacementIdentities` value, moves those sets into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. +- Provide model-native helpers for layer editing, component resolution, variation behavior, axis mapping evaluation, and geometry-derived behavior. `Font::replace_glyph_layers` retains previous layers through cheap `Arc` references, validates a complete hydration/eviction batch into one `HashSet`, moves that set into the final index, mutates uniquely owned fonts in place, and preserves shared snapshots through copy-on-write. - Own canonical glyph and source-metric interpolation value ordering, variation-model construction, interpolation evaluation, and location-bound glyph resolution. - Stay independent of TypeScript, NAPI, and bridge DTOs. @@ -92,7 +92,7 @@ Coordinates, advance width, smooth flags, anchor positions, and component transf `shift-wire` may translate native bases, source values, and projections into transport DTOs, but it must not rebuild source samples, define value ordering or topology compatibility, or evaluate variation models. -`shift-font` should not perform SQLite persistence. Durable working-store reads and writes belong in `shift-store`. The private `codec_adapter` module only adapts one domain layer to codec-owned bytes/views; it does not choose storage, caching, transactions, or loading policy. +`shift-font` should not perform SQLite persistence or define a durable binary encoding. Durable working-store reads, writes, MessagePack encoding, compression, and compatibility policy belong in `shift-store`. `shift-font` should not own Electron, NAPI, or editor state. The TypeScript editor owns UI interaction, selection, hover, camera, tools, and command history. diff --git a/crates/shift-font/src/codec_adapter.rs b/crates/shift-font/src/codec_adapter.rs deleted file mode 100644 index b0f110ba..00000000 --- a/crates/shift-font/src/codec_adapter.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Lossless adaptation between the authoring model and `shift.glyph-layer.v1`. -//! -//! The codec remains independent of `shift-font`; this module owns typed-ID and -//! domain-object validation at the boundary. - -use std::collections::HashMap; -use std::str::FromStr; - -use shift_glyph_codec as codec; - -pub use codec::{PackedGlyphLayer, MAX_LAYER_PAYLOAD_BYTES}; - -use crate::{ - Anchor, AnchorId, Component, ComponentId, Contour, ContourId, DecomposedTransform, GlyphId, - GlyphLayer, GlyphName, Guideline, GuidelineId, LayerId, LibData, LibValue, Point, PointId, - PointType, SourceId, -}; - -#[derive(Debug, thiserror::Error)] -pub enum PackedLayerError { - #[error(transparent)] - Codec(#[from] codec::LayerCodecError), - - #[error("invalid {kind} {value:?}")] - InvalidId { kind: &'static str, value: String }, - - #[error("invalid base glyph name {0:?}")] - InvalidGlyphName(String), -} - -/// Encodes one editable authored layer without involving SQLite or transport. -pub fn pack_glyph_layer(layer: &GlyphLayer) -> Result { - codec::pack_layer(&to_codec_layer(layer)).map_err(Into::into) -} - -/// Validates bytes and materializes exactly one editable authored layer. -pub fn unpack_glyph_layer(bytes: &[u8]) -> Result { - glyph_layer_from_view(codec::decode_layer(bytes)?) -} - -/// Materializes one layer from an already-validated borrowed payload view. -fn glyph_layer_from_view(view: codec::GlyphLayerView<'_>) -> Result { - let layer_id = parse_id::("layer id", view.id())?; - let source_id = parse_id::("source id", view.source_id())?; - let mut layer = GlyphLayer::with_width(layer_id, source_id, view.width()); - layer.set_height(view.height()); - - for contour in view.contours() { - let contour_id = parse_id::("contour id", contour.id())?; - let mut decoded = Contour::with_id(contour_id); - for point in contour.points() { - decoded.push_point(Point::new( - parse_id::("point id", point.id())?, - point.x(), - point.y(), - from_codec_point_type(point.point_type()), - point.smooth(), - )); - } - if contour.closed() { - decoded.close(); - } - layer.add_contour(decoded); - } - - for component in view.components() { - let name = GlyphName::new(component.base_glyph_name()).map_err(|_| { - PackedLayerError::InvalidGlyphName(component.base_glyph_name().to_owned()) - })?; - let transform = component.transform(); - layer.add_component(Component::with_id( - parse_id::("component id", component.id())?, - parse_id::("base glyph id", component.base_glyph_id())?, - name, - DecomposedTransform { - translate_x: transform.translate_x, - translate_y: transform.translate_y, - rotation: transform.rotation, - scale_x: transform.scale_x, - scale_y: transform.scale_y, - skew_x: transform.skew_x, - skew_y: transform.skew_y, - t_center_x: transform.center_x, - t_center_y: transform.center_y, - }, - )); - } - - for anchor in view.anchors() { - layer.add_anchor(Anchor::with_id( - parse_id::("anchor id", anchor.id())?, - anchor.name().map(str::to_owned), - anchor.x(), - anchor.y(), - )); - } - - for guideline in view.guidelines() { - layer.add_guideline(Guideline::with_id( - parse_id::("guideline id", guideline.id())?, - guideline.x(), - guideline.y(), - guideline.angle(), - guideline.name().map(str::to_owned), - guideline.color().map(str::to_owned), - )); - } - - *layer.lib_mut() = LibData::from_map( - view.lib() - .into_iter() - .map(|(key, value)| (key, from_codec_lib_value(value))) - .collect(), - ); - Ok(layer) -} - -fn to_codec_layer(layer: &GlyphLayer) -> codec::GlyphLayer { - codec::GlyphLayer { - id: layer.id().as_str().to_owned(), - source_id: layer.source_id().as_str().to_owned(), - width: layer.width(), - height: layer.height(), - contours: layer - .contours_iter() - .map(|contour| codec::LayerContour { - id: contour.id().as_str().to_owned(), - closed: contour.is_closed(), - points: contour - .points() - .iter() - .map(|point| codec::LayerPoint { - id: point.id().as_str().to_owned(), - point_type: to_codec_point_type(point.point_type()), - smooth: point.is_smooth(), - x: point.x(), - y: point.y(), - }) - .collect(), - }) - .collect(), - components: layer - .components_iter() - .map(|component| { - let transform = component.transform(); - codec::LayerComponent { - id: component.id().as_str().to_owned(), - base_glyph_id: component.base_glyph_id().as_str().to_owned(), - base_glyph_name: component.base_glyph_name().as_str().to_owned(), - transform: codec::LayerTransform { - translate_x: transform.translate_x, - translate_y: transform.translate_y, - rotation: transform.rotation, - scale_x: transform.scale_x, - scale_y: transform.scale_y, - skew_x: transform.skew_x, - skew_y: transform.skew_y, - center_x: transform.t_center_x, - center_y: transform.t_center_y, - }, - } - }) - .collect(), - anchors: layer - .anchors_iter() - .map(|anchor| codec::LayerAnchor { - id: anchor.id().as_str().to_owned(), - name: anchor.name().map(str::to_owned), - x: anchor.x(), - y: anchor.y(), - }) - .collect(), - guidelines: layer - .guidelines() - .iter() - .map(|guideline| codec::LayerGuideline { - id: guideline.id().as_str().to_owned(), - x: guideline.x(), - y: guideline.y(), - angle: guideline.angle(), - name: guideline.name().map(str::to_owned), - color: guideline.color().map(str::to_owned), - }) - .collect(), - lib: layer - .lib() - .iter() - .map(|(key, value)| (key.clone(), to_codec_lib_value(value))) - .collect(), - } -} - -fn to_codec_point_type(point_type: PointType) -> codec::LayerPointType { - match point_type { - PointType::OnCurve => codec::LayerPointType::OnCurve, - PointType::OffCurve => codec::LayerPointType::OffCurve, - PointType::QCurve => codec::LayerPointType::QCurve, - } -} - -fn from_codec_point_type(point_type: codec::LayerPointType) -> PointType { - match point_type { - codec::LayerPointType::OnCurve => PointType::OnCurve, - codec::LayerPointType::OffCurve => PointType::OffCurve, - codec::LayerPointType::QCurve => PointType::QCurve, - } -} - -fn to_codec_lib_value(value: &LibValue) -> codec::LayerLibValue { - match value { - LibValue::String(value) => codec::LayerLibValue::String(value.clone()), - LibValue::Integer(value) => codec::LayerLibValue::Integer(*value), - LibValue::UnsignedInteger(value) => codec::LayerLibValue::UnsignedInteger(*value), - LibValue::Float(value) => codec::LayerLibValue::Float(*value), - LibValue::Boolean(value) => codec::LayerLibValue::Boolean(*value), - LibValue::Array(values) => { - codec::LayerLibValue::Array(values.iter().map(to_codec_lib_value).collect()) - } - LibValue::Dict(values) => codec::LayerLibValue::Dict( - values - .iter() - .map(|(key, value)| (key.clone(), to_codec_lib_value(value))) - .collect(), - ), - LibValue::Data(value) => codec::LayerLibValue::Data(value.clone()), - LibValue::Date(value) => codec::LayerLibValue::Date(value.clone()), - LibValue::Uid(value) => codec::LayerLibValue::Uid(*value), - } -} - -fn from_codec_lib_value(value: codec::LayerLibValue) -> LibValue { - match value { - codec::LayerLibValue::String(value) => LibValue::String(value), - codec::LayerLibValue::Integer(value) => LibValue::Integer(value), - codec::LayerLibValue::UnsignedInteger(value) => LibValue::UnsignedInteger(value), - codec::LayerLibValue::Float(value) => LibValue::Float(value), - codec::LayerLibValue::Boolean(value) => LibValue::Boolean(value), - codec::LayerLibValue::Array(values) => { - LibValue::Array(values.into_iter().map(from_codec_lib_value).collect()) - } - codec::LayerLibValue::Dict(values) => LibValue::Dict( - values - .into_iter() - .map(|(key, value)| (key, from_codec_lib_value(value))) - .collect::>(), - ), - codec::LayerLibValue::Data(value) => LibValue::Data(value), - codec::LayerLibValue::Date(value) => LibValue::Date(value), - codec::LayerLibValue::Uid(value) => LibValue::Uid(value), - } -} - -fn parse_id(kind: &'static str, value: &str) -> Result -where - T: FromStr, -{ - value.parse().map_err(|_| PackedLayerError::InvalidId { - kind, - value: value.to_owned(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::sample_font; - - #[test] - fn complete_domain_layer_roundtrips_through_canonical_bytes() { - let font = sample_font(); - let glyph = font.glyph_by_name("A").unwrap(); - let layer = glyph - .layers() - .values() - .find(|layer| !layer.lib().is_empty()) - .unwrap(); - - let packed = pack_glyph_layer(layer).unwrap(); - let decoded = unpack_glyph_layer(packed.as_bytes()).unwrap(); - - assert_eq!(&decoded, layer.as_ref()); - assert_eq!(pack_glyph_layer(&decoded).unwrap(), packed); - } - - #[test] - fn adapter_rejects_codec_valid_but_domain_invalid_ids() { - let encoded = - codec::pack_layer(&codec::GlyphLayer::empty("wrong", "source_regular")).unwrap(); - - assert!(matches!( - unpack_glyph_layer(encoded.as_bytes()), - Err(PackedLayerError::InvalidId { - kind: "layer id", - .. - }) - )); - } - - #[test] - fn compatible_source_layers_preserve_shared_structure_and_distinct_values() { - let font = sample_font(); - let glyph = font.glyph_by_name("A").unwrap(); - let reference = glyph - .layers() - .values() - .find(|layer| !layer.lib().is_empty()) - .unwrap(); - let mut source = reference.clone_with_fresh_ids( - LayerId::from_raw("compatible"), - SourceId::from_raw("compatible"), - ); - source.set_width(reference.width() + 50.0); - source.contours_iter_mut().next().unwrap().points_mut()[0].set_position(125.0, 10.0); - assert!(reference - .interpolation_compatibility_with(&source) - .is_compatible()); - - let decoded_reference = - unpack_glyph_layer(pack_glyph_layer(reference).unwrap().as_bytes()).unwrap(); - let decoded_source = - unpack_glyph_layer(pack_glyph_layer(&source).unwrap().as_bytes()).unwrap(); - - assert!(decoded_reference - .interpolation_compatibility_with(&decoded_source) - .is_compatible()); - assert_ne!( - decoded_reference.contours_iter().next().unwrap().points()[0].x(), - decoded_source.contours_iter().next().unwrap().points()[0].x() - ); - } -} diff --git a/crates/shift-font/src/ir/entity.rs b/crates/shift-font/src/ir/entity.rs index 9998e30e..21766ffb 100644 --- a/crates/shift-font/src/ir/entity.rs +++ b/crates/shift-font/src/ir/entity.rs @@ -1,4 +1,5 @@ -use serde::{Deserialize, Serialize}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize}; const SHORT_ID_ALPHABET: &[u8; 64] = b"useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; @@ -36,7 +37,7 @@ impl std::fmt::Display for EntityId { macro_rules! typed_id { ($name:ident, $prefix:literal) => { - #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)] + #[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize)] pub struct $name(String); impl Default for $name { @@ -84,6 +85,16 @@ macro_rules! typed_id { } } } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } + } }; } @@ -133,6 +144,45 @@ typed_id!(AxisMappingId, "axisMapping"); typed_id!(NamedInstanceId, "namedInstance"); typed_id!(MetricId, "metric"); +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub enum GlyphEntityId { + Contour(ContourId), + Point(PointId), + Component(ComponentId), + Anchor(AnchorId), + Guideline(GuidelineId), +} + +impl From for GlyphEntityId { + fn from(id: ContourId) -> Self { + Self::Contour(id) + } +} + +impl From for GlyphEntityId { + fn from(id: PointId) -> Self { + Self::Point(id) + } +} + +impl From for GlyphEntityId { + fn from(id: ComponentId) -> Self { + Self::Component(id) + } +} + +impl From for GlyphEntityId { + fn from(id: AnchorId) -> Self { + Self::Anchor(id) + } +} + +impl From for GlyphEntityId { + fn from(id: GuidelineId) -> Self { + Self::Guideline(id) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/shift-font/src/ir/font.rs b/crates/shift-font/src/ir/font.rs index a53c8553..135b09e0 100644 --- a/crates/shift-font/src/ir/font.rs +++ b/crates/shift-font/src/ir/font.rs @@ -2,8 +2,7 @@ use crate::axis::{Axis, AxisMapping, Location}; use crate::binary_data::BinaryData; use crate::collection::EntityList; use crate::entity::{ - AnchorId, AxisId, ComponentId, ContourId, GlyphId, GuidelineId, LayerId, MetricId, PointId, - SourceId, + AnchorId, AxisId, ContourId, GlyphEntityId, GlyphId, LayerId, MetricId, PointId, SourceId, }; use crate::error::{CoreError, CoreResult}; use crate::features::FeatureData; @@ -121,26 +120,13 @@ struct FontData { images: BinaryData, } -#[derive(Default)] -struct ReplacementIdentities { - contours: HashSet, - points: HashSet, - components: HashSet, - anchors: HashSet, - guidelines: HashSet, -} - #[derive(Clone, Debug, Default)] struct FontIndex { glyph_by_name: HashMap, layer_owner: HashMap, layer_by_glyph_source: HashMap<(GlyphId, SourceId), LayerId>, glyphs_by_unicode: HashMap>, - contour_ids: HashSet, - point_ids: HashSet, - component_ids: HashSet, - anchor_ids: HashSet, - guideline_ids: HashSet, + entity_ids: HashSet, } impl FontIndex { @@ -168,11 +154,7 @@ impl FontIndex { } let mut local_sources = HashSet::new(); - let mut local_contours = HashSet::new(); - let mut local_points = HashSet::new(); - let mut local_components = HashSet::new(); - let mut local_anchors = HashSet::new(); - let mut local_guidelines = HashSet::new(); + let mut local_entities = HashSet::new(); for layer in glyph.layers().values().map(Arc::as_ref) { if self.layer_owner.contains_key(&layer.id()) { @@ -190,38 +172,10 @@ impl FontIndex { }); } - for contour in layer.contours_iter() { - if self.contour_ids.contains(&contour.id()) || !local_contours.insert(contour.id()) + for entity_id in glyph_entity_ids(layer) { + if self.entity_ids.contains(&entity_id) || !local_entities.insert(entity_id.clone()) { - return Err(CoreError::DuplicateContourId(contour.id())); - } - - for point in contour.points() { - if self.point_ids.contains(&point.id()) || !local_points.insert(point.id()) { - return Err(CoreError::DuplicatePointId(point.id())); - } - } - } - - for component in layer.components_iter() { - if self.component_ids.contains(&component.id()) - || !local_components.insert(component.id()) - { - return Err(CoreError::DuplicateComponentId(component.id())); - } - } - - for anchor in layer.anchors_iter() { - if self.anchor_ids.contains(&anchor.id()) || !local_anchors.insert(anchor.id()) { - return Err(CoreError::DuplicateAnchorId(anchor.id())); - } - } - - for guideline in layer.guidelines() { - if self.guideline_ids.contains(&guideline.id()) - || !local_guidelines.insert(guideline.id()) - { - return Err(CoreError::DuplicateGuidelineId(guideline.id())); + return Err(duplicate_entity_error(entity_id)); } } } @@ -244,41 +198,10 @@ impl FontIndex { }); } - let mut contour_ids = HashSet::new(); - let mut point_ids = HashSet::new(); - let mut component_ids = HashSet::new(); - let mut anchor_ids = HashSet::new(); - let mut guideline_ids = HashSet::new(); - - for contour in layer.contours_iter() { - if self.contour_ids.contains(&contour.id()) || !contour_ids.insert(contour.id()) { - return Err(CoreError::DuplicateContourId(contour.id())); - } - - for point in contour.points() { - if self.point_ids.contains(&point.id()) || !point_ids.insert(point.id()) { - return Err(CoreError::DuplicatePointId(point.id())); - } - } - } - - for component in layer.components_iter() { - if self.component_ids.contains(&component.id()) || !component_ids.insert(component.id()) - { - return Err(CoreError::DuplicateComponentId(component.id())); - } - } - - for anchor in layer.anchors_iter() { - if self.anchor_ids.contains(&anchor.id()) || !anchor_ids.insert(anchor.id()) { - return Err(CoreError::DuplicateAnchorId(anchor.id())); - } - } - - for guideline in layer.guidelines() { - if self.guideline_ids.contains(&guideline.id()) || !guideline_ids.insert(guideline.id()) - { - return Err(CoreError::DuplicateGuidelineId(guideline.id())); + let mut local_entities = HashSet::new(); + for entity_id in glyph_entity_ids(layer) { + if self.entity_ids.contains(&entity_id) || !local_entities.insert(entity_id.clone()) { + return Err(duplicate_entity_error(entity_id)); } } @@ -288,72 +211,19 @@ impl FontIndex { fn validate_layer_replacements( &self, replacements: &[(GlyphId, Arc, Arc)], - ) -> CoreResult { - let mut removed_contour_ids = HashSet::new(); - let mut removed_point_ids = HashSet::new(); - let mut removed_component_ids = HashSet::new(); - let mut removed_anchor_ids = HashSet::new(); - let mut removed_guideline_ids = HashSet::new(); - for (_, previous, _) in replacements { - for contour in previous.contours_iter() { - removed_contour_ids.insert(contour.id()); - removed_point_ids.extend(contour.points().iter().map(|point| point.id())); - } - removed_component_ids - .extend(previous.components_iter().map(|component| component.id())); - removed_anchor_ids.extend(previous.anchors_iter().map(|anchor| anchor.id())); - removed_guideline_ids.extend(previous.guidelines().iter().map(Guideline::id)); - } + ) -> CoreResult> { + let removed_ids = replacements + .iter() + .flat_map(|(_, previous, _)| glyph_entity_ids(previous)) + .collect::>(); + let mut replacement_ids = HashSet::new(); - let mut replacement_ids = ReplacementIdentities::default(); for (_, _, replacement) in replacements { - for contour in replacement.contours_iter() { - let contour_id = contour.id(); - if (self.contour_ids.contains(&contour_id) - && !removed_contour_ids.contains(&contour_id)) - || !replacement_ids.contours.insert(contour_id.clone()) + for entity_id in glyph_entity_ids(replacement) { + if (self.entity_ids.contains(&entity_id) && !removed_ids.contains(&entity_id)) + || !replacement_ids.insert(entity_id.clone()) { - return Err(CoreError::DuplicateContourId(contour_id)); - } - - for point in contour.points() { - let point_id = point.id(); - if (self.point_ids.contains(&point_id) - && !removed_point_ids.contains(&point_id)) - || !replacement_ids.points.insert(point_id.clone()) - { - return Err(CoreError::DuplicatePointId(point_id)); - } - } - } - - for component in replacement.components_iter() { - let component_id = component.id(); - if (self.component_ids.contains(&component_id) - && !removed_component_ids.contains(&component_id)) - || !replacement_ids.components.insert(component_id.clone()) - { - return Err(CoreError::DuplicateComponentId(component_id)); - } - } - - for anchor in replacement.anchors_iter() { - let anchor_id = anchor.id(); - if (self.anchor_ids.contains(&anchor_id) - && !removed_anchor_ids.contains(&anchor_id)) - || !replacement_ids.anchors.insert(anchor_id.clone()) - { - return Err(CoreError::DuplicateAnchorId(anchor_id)); - } - } - - for guideline in replacement.guidelines() { - let guideline_id = guideline.id(); - if (self.guideline_ids.contains(&guideline_id) - && !removed_guideline_ids.contains(&guideline_id)) - || !replacement_ids.guidelines.insert(guideline_id.clone()) - { - return Err(CoreError::DuplicateGuidelineId(guideline_id)); + return Err(duplicate_entity_error(entity_id)); } } } @@ -366,17 +236,7 @@ impl FontIndex { self.layer_by_glyph_source .insert((glyph_id, layer.source_id()), layer.id()); - for contour in layer.contours_iter() { - self.contour_ids.insert(contour.id()); - self.point_ids - .extend(contour.points().iter().map(|point| point.id())); - } - self.component_ids - .extend(layer.components_iter().map(|component| component.id())); - self.anchor_ids - .extend(layer.anchors_iter().map(|anchor| anchor.id())); - self.guideline_ids - .extend(layer.guidelines().iter().map(Guideline::id)); + self.entity_ids.extend(glyph_entity_ids(layer)); } fn remove_layer(&mut self, glyph_id: GlyphId, layer: &GlyphLayer) { @@ -384,20 +244,8 @@ impl FontIndex { self.layer_by_glyph_source .remove(&(glyph_id, layer.source_id())); - for contour in layer.contours_iter() { - self.contour_ids.remove(&contour.id()); - for point in contour.points() { - self.point_ids.remove(&point.id()); - } - } - for component in layer.components_iter() { - self.component_ids.remove(&component.id()); - } - for anchor in layer.anchors_iter() { - self.anchor_ids.remove(&anchor.id()); - } - for guideline in layer.guidelines() { - self.guideline_ids.remove(&guideline.id()); + for entity_id in glyph_entity_ids(layer) { + self.entity_ids.remove(&entity_id); } } @@ -435,6 +283,44 @@ impl FontIndex { } } +fn glyph_entity_ids(layer: &GlyphLayer) -> impl Iterator + '_ { + layer + .contours_iter() + .map(|contour| GlyphEntityId::from(contour.id())) + .chain( + layer + .contours_iter() + .flat_map(|contour| contour.points().iter()) + .map(|point| GlyphEntityId::from(point.id())), + ) + .chain( + layer + .components_iter() + .map(|component| GlyphEntityId::from(component.id())), + ) + .chain( + layer + .anchors_iter() + .map(|anchor| GlyphEntityId::from(anchor.id())), + ) + .chain( + layer + .guidelines() + .iter() + .map(|guideline| GlyphEntityId::from(guideline.id())), + ) +} + +fn duplicate_entity_error(id: GlyphEntityId) -> CoreError { + match id { + GlyphEntityId::Contour(id) => CoreError::DuplicateContourId(id), + GlyphEntityId::Point(id) => CoreError::DuplicatePointId(id), + GlyphEntityId::Component(id) => CoreError::DuplicateComponentId(id), + GlyphEntityId::Anchor(id) => CoreError::DuplicateAnchorId(id), + GlyphEntityId::Guideline(id) => CoreError::DuplicateGuidelineId(id), + } +} + impl FontState { fn from_data(data: FontData) -> CoreResult { let index = FontIndex::from_glyphs(&data.glyphs)?; @@ -1044,45 +930,66 @@ impl Font { /// Returns whether a contour identity is already in use anywhere in the font. pub(crate) fn has_contour_id(&self, contour_id: &ContourId) -> bool { - self.index().contour_ids.contains(contour_id) + self.index() + .entity_ids + .contains(&GlyphEntityId::from(contour_id.clone())) } /// Returns whether a point identity is already in use anywhere in the font. pub(crate) fn has_point_id(&self, point_id: &PointId) -> bool { - self.index().point_ids.contains(point_id) + self.index() + .entity_ids + .contains(&GlyphEntityId::from(point_id.clone())) } /// Returns whether an anchor identity is already in use anywhere in the font. pub(crate) fn has_anchor_id(&self, anchor_id: &AnchorId) -> bool { - self.index().anchor_ids.contains(anchor_id) + self.index() + .entity_ids + .contains(&GlyphEntityId::from(anchor_id.clone())) } /// Records a contour minted by an in-place layer edit. pub(crate) fn record_contour_id(&mut self, contour_id: ContourId) { - self.state_mut().index.contour_ids.insert(contour_id); + self.state_mut() + .index + .entity_ids + .insert(GlyphEntityId::from(contour_id)); } /// Records points minted by an in-place layer edit. pub(crate) fn record_point_ids(&mut self, point_ids: impl IntoIterator) { - self.state_mut().index.point_ids.extend(point_ids); + self.state_mut() + .index + .entity_ids + .extend(point_ids.into_iter().map(GlyphEntityId::from)); } /// Records anchors minted by an in-place layer edit. pub(crate) fn record_anchor_ids(&mut self, anchor_ids: impl IntoIterator) { - self.state_mut().index.anchor_ids.extend(anchor_ids); + self.state_mut() + .index + .entity_ids + .extend(anchor_ids.into_iter().map(GlyphEntityId::from)); } /// Forgets point identities removed by an in-place layer edit. pub(crate) fn forget_point_ids(&mut self, point_ids: &[PointId]) { for point_id in point_ids { - self.state_mut().index.point_ids.remove(point_id); + self.state_mut() + .index + .entity_ids + .remove(&GlyphEntityId::from(point_id.clone())); } } /// Forgets anchor identities removed by an in-place layer edit. pub(crate) fn forget_anchor_ids(&mut self, anchor_ids: &[AnchorId]) { for anchor_id in anchor_ids { - self.state_mut().index.anchor_ids.remove(anchor_id); + self.state_mut() + .index + .entity_ids + .remove(&GlyphEntityId::from(anchor_id.clone())); } } @@ -1275,11 +1182,7 @@ impl Font { .expect("replacement owner was resolved before mutation"); Arc::make_mut(glyph).set_layer(layer); } - state.index.contour_ids.extend(replacement_ids.contours); - state.index.point_ids.extend(replacement_ids.points); - state.index.component_ids.extend(replacement_ids.components); - state.index.anchor_ids.extend(replacement_ids.anchors); - state.index.guideline_ids.extend(replacement_ids.guidelines); + state.index.entity_ids.extend(replacement_ids); Ok(()) } @@ -1542,8 +1445,8 @@ fn validate_axis_mappings(axes: &[Axis], mappings: &[AxisMapping]) -> CoreResult mod tests { use super::*; use crate::{ - test_support::sample_font, Anchor, AxisMappingPoint, AxisRole, Component, Contour, - ContourId, GlyphLayer, LayerId, PointId, PointType, + test_support::sample_font, Anchor, AxisMappingPoint, AxisRole, Component, ComponentId, + Contour, ContourId, GlyphLayer, GuidelineId, LayerId, PointId, PointType, }; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -1939,7 +1842,10 @@ mod tests { assert!(font.has_contour_id(&contour_id)); assert!(font.has_point_id(&point_id)); assert!(font.has_anchor_id(&anchor_id)); - assert!(font.index().component_ids.contains(&component_id)); + assert!(font + .index() + .entity_ids + .contains(&GlyphEntityId::from(component_id))); let committed = font.layer(layer_id.clone()).unwrap().clone(); let mut invalid = values.into_vec(); diff --git a/crates/shift-font/src/ir/glyph_name.rs b/crates/shift-font/src/ir/glyph_name.rs index c50667e9..2f43a3e4 100644 --- a/crates/shift-font/src/ir/glyph_name.rs +++ b/crates/shift-font/src/ir/glyph_name.rs @@ -1,9 +1,10 @@ -use serde::{Deserialize, Serialize}; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize}; use std::borrow::Borrow; use std::fmt; use std::ops::Deref; -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] #[serde(transparent)] pub struct GlyphName(String); @@ -30,6 +31,16 @@ impl GlyphName { } } +impl<'de> Deserialize<'de> for GlyphName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(D::Error::custom) + } +} + impl From for GlyphName { fn from(value: String) -> Self { Self::new(value).expect("glyph name must not be empty") diff --git a/crates/shift-font/src/ir/lib_data.rs b/crates/shift-font/src/ir/lib_data.rs index a47f5541..7c542a97 100644 --- a/crates/shift-font/src/ir/lib_data.rs +++ b/crates/shift-font/src/ir/lib_data.rs @@ -1,9 +1,10 @@ +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; -use std::collections::HashMap; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct LibData { - data: HashMap, + data: BTreeMap, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -16,7 +17,7 @@ pub enum LibValue { Float(f64), Boolean(bool), Array(Vec), - Dict(HashMap), + Dict(BTreeMap), Data(Vec), /// A plist date, carried as the RFC 3339 / ISO 8601 string used by XML /// plists (`plist::Date::to_xml_format`), e.g. `2024-02-02T02:02:02Z`. @@ -29,7 +30,7 @@ pub enum LibValue { impl LibData { pub fn new() -> Self { Self { - data: HashMap::new(), + data: BTreeMap::new(), } } @@ -61,17 +62,19 @@ impl LibData { self.data.iter() } - pub fn into_inner(self) -> HashMap { + pub fn into_inner(self) -> BTreeMap { self.data } - pub fn from_map(data: HashMap) -> Self { - Self { data } + pub fn from_map(data: impl IntoIterator) -> Self { + Self { + data: data.into_iter().collect(), + } } } -impl From> for LibData { - fn from(data: HashMap) -> Self { +impl From> for LibData { + fn from(data: BTreeMap) -> Self { Self { data } } } diff --git a/crates/shift-font/src/ir/mod.rs b/crates/shift-font/src/ir/mod.rs index dd1f6a41..1d72ecb7 100644 --- a/crates/shift-font/src/ir/mod.rs +++ b/crates/shift-font/src/ir/mod.rs @@ -29,8 +29,8 @@ pub use boolean::{boolean, BooleanOp}; pub use component::{Component, DecomposedTransform, Transform}; pub use contour::{Contour, Contours}; pub use entity::{ - AnchorId, AxisId, AxisLabelId, AxisMappingId, ComponentId, ContourId, EntityId, GlyphId, - GuidelineId, LayerId, MetricId, NamedInstanceId, PointId, SourceId, + AnchorId, AxisId, AxisLabelId, AxisMappingId, ComponentId, ContourId, EntityId, GlyphEntityId, + GlyphId, GuidelineId, LayerId, MetricId, NamedInstanceId, PointId, SourceId, }; pub use features::FeatureData; pub use font::{Font, FontMetadata}; diff --git a/crates/shift-font/src/lib.rs b/crates/shift-font/src/lib.rs index e561b1c1..44c3224d 100644 --- a/crates/shift-font/src/lib.rs +++ b/crates/shift-font/src/lib.rs @@ -30,7 +30,6 @@ //! `shift-wire`. UI interaction belongs to the TypeScript editor. //! pub mod changes; -mod codec_adapter; pub mod composite; pub mod curve; pub mod error; @@ -43,10 +42,6 @@ pub mod projection; pub mod test_support; pub use changes::*; -pub use codec_adapter::{ - pack_glyph_layer, unpack_glyph_layer, PackedGlyphLayer, PackedLayerError, - MAX_LAYER_PAYLOAD_BYTES, -}; pub use error::{CoreError, CoreResult}; pub use intents::*; pub use interpolation::*; diff --git a/crates/shift-font/src/test_support.rs b/crates/shift-font/src/test_support.rs index d19d15c1..5deb8e78 100644 --- a/crates/shift-font/src/test_support.rs +++ b/crates/shift-font/src/test_support.rs @@ -7,7 +7,7 @@ use crate::{ LibValue, Location, MetricKind, MetricValue, NamedInstance, NamedInstanceId, Point, PointId, PointType, Source, SourceId, }; -use std::collections::HashMap; +use std::collections::BTreeMap; /// Builds a kitchen-sink font corpus for semantic round-trip tests. /// @@ -58,7 +58,7 @@ pub fn sample_font() -> Font { font_guideline.set_color(Some("green".to_string())); font.add_guideline(font_guideline); - let mut nested = HashMap::new(); + let mut nested = BTreeMap::new(); nested.insert( "nestedString".to_string(), LibValue::String("value".to_string()), @@ -90,7 +90,7 @@ pub fn sample_font() -> Font { ); font.fontinfo_remainder_mut() .set("openTypeOS2WeightClass".to_string(), LibValue::Integer(700)); - let mut woff_metadata = HashMap::new(); + let mut woff_metadata = BTreeMap::new(); woff_metadata.insert( "id".to_string(), LibValue::String("dogfood-unique-id".to_string()), diff --git a/crates/shift-glyph-codec/Cargo.toml b/crates/shift-glyph-codec/Cargo.toml deleted file mode 100644 index 07ad34ad..00000000 --- a/crates/shift-glyph-codec/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "shift-glyph-codec" -version = "0.1.0" -edition = "2021" -description = "Strict packed glyph payload codecs shared by Shift transports and stores" -license = "MIT OR Apache-2.0" - -[lib] -crate-type = ["rlib"] - -[dev-dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sha2 = "0.10" diff --git a/crates/shift-glyph-codec/SPECIFICATION.md b/crates/shift-glyph-codec/SPECIFICATION.md deleted file mode 100644 index cddeff9a..00000000 --- a/crates/shift-glyph-codec/SPECIFICATION.md +++ /dev/null @@ -1,336 +0,0 @@ -# Shift glyph-codec specification - -Status: normative for `shift.glyph-outline.v1` and `shift.glyph-layer.v1` - -## Compatibility and ownership - -The glyph-codec family uses one common frame and independently versioned payload -kinds. The byte specification and shared golden vectors define compatibility; -implementations do not define the format by accident. - -- `shift.glyph-outline.v1` (`payloadKind = 0x01`, `formatVersion = 0x01`) is a - derived, flattened preview/rendering outline. Components are already resolved, - coordinates are drawing coordinates, and f64-to-f32 loss is permitted. It is - never authoritative editable glyph state. -- `shift.glyph-layer.v1` (`payloadKind = 0x02`, `formatVersion = 0x01`) is one - canonical authored glyph layer. It preserves stable identities, authored point - semantics and ordering, contour closure, smooth flags, components and - transforms, anchors, guidelines, lib metadata, and authored f64 precision. - -`PackedGlyphLayer` and `PackedGlyphOutline` are distinct opaque values. Outline -bytes must never be persisted or written back as a glyph layer. - -An incompatible interpretation uses a new version scoped to its payload kind. -Unknown kinds, versions, flag bits, point types, and lib tags are errors. Codec -implementations own framing, validation, errors, borrowed iteration, and opaque -packed values; they do not own font semantics, transport, persistence, DOM, -canvas, SQLite, or derived Slug/GPU layouts. - -## Common frame - -All multibyte integers and IEEE-754 values are little-endian. - -```text -u8[4] magic ASCII "SHFT" -u8 payloadKind 0x01 = glyph-outline; 0x02 = glyph-layer -u8 formatVersion version scoped to payloadKind -u16 flags zero in both v1 formats -``` - -## `shift.glyph-outline.v1` - -One buffer represents one resolved outline. - -```text -u8[4] magic "SHFT" -u8 payloadKind 0x01 -u8 formatVersion 0x01 -u16 flags 0 -u32 commandCount -u32 coordCount number of f32 values, not points -u8[commandCount] commands -u8[...] zero padding coordinate section begins at a 4-byte offset -f32[coordCount] coordinates -``` - -Its exact byte length is: - -```text -16 + align4(commandCount) + 4 * coordCount -``` - -No trailing bytes are permitted. - -### Commands - -| Byte | Command | Coordinates consumed | -| ---: | --- | ---: | -| `0` | `Move(x, y)` | 2 | -| `1` | `Line(x, y)` | 2 | -| `2` | `Quad(cx, cy, x, y)` | 4 | -| `3` | `Cubic(c1x, c1y, c2x, c2y, x, y)` | 6 | -| `4` | `Close` | 0 | - -The general segment set is intentional. Any quadratic-only Slug reduction is a -consumer transformation after decode. - -### Command state - -- An empty outline is valid only when both counts are zero. -- Every non-empty contour begins with `Move`. -- `Line`, `Quad`, and `Cubic` require an active contour. -- `Close` requires an active contour with at least one drawing segment and ends - that contour. -- After `Close`, only `Move` or the end of the stream is legal. -- A new `Move` may end an open contour and begin another. -- Open contours are legal. -- The command arity sum equals `coordCount` exactly. - -### Coordinates - -Coordinates are finite IEEE-754 binary32 values. An encoder receiving f64 -coordinates rejects non-finite inputs and finite inputs whose conversion is not -a finite f32. NaN and either infinity are invalid on decode. - -### Strict validation - -A decoder returns either one complete validated view or an error. It rejects: - -- truncated framing or body sections; -- wrong magic, kind, version, or non-zero flags; -- unknown commands and illegal state transitions; -- an arity sum different from `coordCount`; -- non-zero alignment padding; -- non-finite coordinates; -- arithmetic overflow and configured implementation-limit violations; -- lengths inconsistent with counts and all trailing bytes. - -V1 implementations in this repository apply the same explicit limits before -allocation or command iteration: - -- at most 1,000,000 commands; -- at most 6,000,000 coordinates; -- at most 32 MiB total payload bytes. - -## `shift.glyph-layer.v1` - -One buffer represents one independently replaceable authored glyph layer. The -layer and source identities are included so a payload cannot be detached from -its semantic ownership accidentally. - -### Fixed header - -The fixed header is 72 bytes: - -| Offset | Type | Field | -| ---: | --- | --- | -| 0 | `u8[4]` | magic `SHFT` | -| 4 | `u8` | payload kind `0x02` | -| 5 | `u8` | format version `0x01` | -| 6 | `u16` | frame flags, zero | -| 8 | `u32` | exact total payload byte length | -| 12 | `u32` | distinct string count | -| 16 | `u32` | combined UTF-8 string byte length | -| 20 | `u32` | contour count | -| 24 | `u32` | total point count across contours | -| 28 | `u32` | component count | -| 32 | `u32` | anchor count | -| 36 | `u32` | guideline count | -| 40 | `u32` | encoded layer-lib byte length | -| 44 | `u32` | layer-ID string index | -| 48 | `u32` | source-ID string index | -| 52 | `u32` | layer flags; bit 0 means height is present | -| 56 | `f64` | width | -| 64 | `f64` | height, or positive-zero bits when absent | - -All count and byte-length fields describe the uncompressed v1 payload. V1 has no -compression flag. Compression belongs in an explicitly versioned outer storage -record or a future format revision. - -### Canonical string table - -The fixed header is followed by: - -```text -u32[stringCount + 1] offsets -u8[stringBytes] values -``` - -Offsets are relative to `values`, begin at zero, never decrease, and end at -`stringBytes`. Each slice is strict UTF-8. Values are unique, including empty -strings. - -Every string-bearing record stores a `u32` table index. Optional strings use -`0xffffffff` for absence. The table is in first-reference order over the wire: -layer ID, source ID, then each section and nested lib value in the order below. -A decoder rejects skipped first references and unreferenced table entries. This -makes equivalent inputs encode byte-for-byte identically without imposing a -lexical sort on identities or authored entity order. - -### Contours and points - -Contours preserve authored order. Each contour is: - -```text -u32 idString -u32 pointCount -u32 flags bit 0 = closed; all other bits zero -Point[pointCount] -``` - -Each 24-byte point is: - -```text -u32 idString -u8 pointType 0 = on-curve; 1 = off-curve; 2 = qcurve -u8 flags bit 0 = smooth; all other bits zero -u16 reserved zero -f64 x -f64 y -``` - -Empty open or closed contours are representable because authored structure is -preserved rather than inferred from drawing commands. Point IDs must be unique -within the payload; contour IDs must likewise be unique. - -### Components - -Components preserve authored order. Each 88-byte component is: - -```text -u32 idString -u32 baseGlyphIdString -u32 baseGlyphNameString -u32 reserved zero -f64 translateX -f64 translateY -f64 rotation -f64 scaleX -f64 scaleY -f64 skewX -f64 skewY -f64 transformCenterX -f64 transformCenterY -``` - -The decomposed authored transform is preserved directly. Components are not -flattened, and base glyph identity is not inferred from the editable name. -Component IDs must be unique within the payload. - -### Anchors - -Anchors preserve authored order. Each 24-byte anchor is: - -```text -u32 idString -u32 nameString or 0xffffffff -f64 x -f64 y -``` - -Anchor IDs must be unique within the payload. - -### Guidelines - -Guidelines preserve authored order. Each 40-byte guideline is: - -```text -u32 idString -u32 nameString or 0xffffffff -u32 colorString or 0xffffffff -u32 flags bit 0 = x present; bit 1 = y present; bit 2 = angle present -f64 x positive-zero bits when absent -f64 y positive-zero bits when absent -f64 angle positive-zero bits when absent -``` - -Guideline IDs must be unique within the payload. - -### Layer lib - -The final section has exactly the header's `layerLibBytes`. Its root is a -canonical dictionary: - -```text -u32 entryCount -Entry[entryCount] - -Entry := u32 keyString, Value value -Value := u8 tag, u8 reservedZero, u16 reservedZero, tag-specific payload -``` - -Dictionary keys are strictly increasing by their UTF-8 bytes. Duplicate keys -are therefore invalid. Arrays preserve authored order. - -| Tag | Value | Payload | -| ---: | --- | --- | -| 0 | string | `u32 stringIndex` | -| 1 | signed integer | `i64` | -| 2 | unsigned integer | `u64` | -| 3 | float | finite `f64` | -| 4 | boolean | `u8` 0 or 1, then three zero bytes | -| 5 | array | `u32 count`, then `Value[count]` | -| 6 | dictionary | root dictionary layout | -| 7 | binary data | `u32 byteLength`, then exact bytes | -| 8 | date | `u32 stringIndex` containing the preserved date text | -| 9 | archive UID | `u64` | - -### Numbers, identity, and order - -All authored numeric fields are finite IEEE-754 binary64 values and preserve -their exact bits, including negative zero when present. Optional absent numeric -slots use positive-zero bits so there is only one canonical encoding. - -Contour, point, component, anchor, and guideline identity is unique within its -entity kind in one payload. Font-wide identity and typed ID prefixes are domain -validation at the `shift-font` adapter boundary. The codec does not mint IDs or -infer missing structure. - -Authored order is preserved for contours, points, components, anchors, -guidelines, arrays, and all other sequence-valued fields. Dictionaries are maps, -not authored sequences, and use canonical UTF-8 key order. - -### Strict validation and limits - -A layer decoder validates the complete payload before exposing any view. It -rejects malformed framing, lengths, counts, offsets, UTF-8, references, -first-use order, duplicate strings or identities, unknown flags/types/tags, -non-zero reserved bytes, non-finite values, non-canonical absent slots, -non-canonical dictionary order, excessive nesting, truncation, and trailing -bytes. - -Repository v1 implementations apply these limits before unbounded allocation or -iteration: - -- 1,000,000 contours; -- 4,000,000 points; -- 1,000,000 components, anchors, and guidelines each; -- 4,000,000 distinct strings and 64 MiB combined UTF-8; -- 1,000,000 nested lib values and recursion depth 64; each value and each dictionary container consumes one recursion level, while an array consumes its value level plus one level per nested value; -- 256 MiB total payload bytes. - -The Rust borrowed view and TypeScript iterable view expose one layer's structure -incrementally. Consumers can build derived projections one contour/entity at a -time without constructing a complete font or retaining every editable layer. - -## Implementation structure - -Both implementations share one frame parser/writer across payload kinds. Rust -keeps outline and layer codecs in separate modules and caches validated layer -offsets in `PackedGlyphLayer`. TypeScript separates frame, binary cursor/writer, -string-table, lib-value, and limit handling from the layer views. Successful -encoding validates writer input and constructs its offsets while writing; only -externally supplied bytes run the complete decoder in release builds. - -## Golden vectors - -Canonical binary vectors are checked in at: - -- `fixtures/glyph-codec/outline-v1/` -- `fixtures/glyph-codec/layer-v1/` - -Each fixture directory includes a `vectors.json` manifest. Dedicated Rust and -TypeScript fixture tests parse those manifests, verify every declared byte -length and SHA-256 digest, decode the bytes, and require canonical re-encoding -to reproduce every byte. Format behavior tests remain separate. Any accepted -canonical payload must round-trip byte-for-byte. diff --git a/crates/shift-glyph-codec/examples/layer_benchmark.rs b/crates/shift-glyph-codec/examples/layer_benchmark.rs deleted file mode 100644 index 12817548..00000000 --- a/crates/shift-glyph-codec/examples/layer_benchmark.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::{collections::BTreeMap, hint::black_box, time::Instant}; - -use shift_glyph_codec::{ - decode_layer, pack_layer, GlyphLayer, LayerAnchor, LayerComponent, LayerContour, LayerPoint, - LayerPointType, LayerTransform, -}; - -fn outline_layer(name: &str, contour_count: usize, points_per_contour: usize) -> GlyphLayer { - let contours = (0..contour_count) - .map(|contour_index| LayerContour { - id: format!("contour_{name}_{contour_index}"), - closed: true, - points: (0..points_per_contour) - .map(|point_index| { - let value = (contour_index * points_per_contour + point_index) as f64; - LayerPoint { - id: format!("point_{name}_{contour_index}_{point_index}"), - point_type: match point_index % 3 { - 0 => LayerPointType::OnCurve, - 1 => LayerPointType::OffCurve, - _ => LayerPointType::QCurve, - }, - smooth: point_index % 5 == 0, - x: value * 0.75, - y: value * -0.375, - } - }) - .collect(), - }) - .collect(); - - GlyphLayer { - id: format!("layer_{name}"), - source_id: "source_regular".to_string(), - width: 1_000.0, - height: Some(1_000.0), - contours, - components: Vec::new(), - anchors: vec![LayerAnchor { - id: format!("anchor_{name}"), - name: Some("top".to_string()), - x: 500.0, - y: 800.0, - }], - guidelines: Vec::new(), - lib: BTreeMap::new(), - } -} - -fn composite_layer() -> GlyphLayer { - let mut layer = outline_layer("composite", 1, 4); - layer.components = (0..8) - .map(|index| LayerComponent { - id: format!("component_{index}"), - base_glyph_id: format!("glyph_base_{index}"), - base_glyph_name: format!("base.{index}"), - transform: LayerTransform { - translate_x: index as f64 * 40.0, - translate_y: index as f64 * 20.0, - rotation: index as f64, - ..LayerTransform::default() - }, - }) - .collect(); - layer -} - -fn run(name: &str, layer: &GlyphLayer, iterations: usize) { - let packed = pack_layer(layer).expect("representative layer should encode"); - for _ in 0..100 { - black_box(pack_layer(black_box(layer)).unwrap()); - black_box(decode_layer(black_box(packed.as_bytes())).unwrap()); - } - - let encode_start = Instant::now(); - for _ in 0..iterations { - black_box(pack_layer(black_box(layer)).unwrap()); - } - let encode_us = encode_start.elapsed().as_secs_f64() * 1_000_000.0 / iterations as f64; - - let decode_start = Instant::now(); - for _ in 0..iterations { - let view = decode_layer(black_box(packed.as_bytes())).unwrap(); - black_box(view.contours().flat_map(|contour| contour.points()).count()); - } - let decode_us = decode_start.elapsed().as_secs_f64() * 1_000_000.0 / iterations as f64; - - println!( - "case={name} payload_bytes={} contours={} points={} components={} encode_us={encode_us:.3} validate_iterate_us={decode_us:.3}", - packed.as_bytes().len(), - packed.view().contour_count(), - packed.view().point_count(), - packed.view().component_count(), - ); -} - -fn run_sparse_multi_source() { - const SOURCE_SLOTS: usize = 13; - const ITERATIONS: usize = 2_000; - let layers = (0..SOURCE_SLOTS) - .filter(|index| index % 4 == 0) - .map(|index| { - let mut layer = outline_layer(&format!("sparse_{index}"), 1, 3); - layer.source_id = format!("source_{index}"); - layer - }) - .collect::>(); - let payload_bytes = layers - .iter() - .map(|layer| pack_layer(layer).unwrap().as_bytes().len()) - .sum::(); - - let start = Instant::now(); - for _ in 0..ITERATIONS { - for layer in &layers { - black_box(pack_layer(black_box(layer)).unwrap()); - } - } - let per_set_us = start.elapsed().as_secs_f64() * 1_000_000.0 / ITERATIONS as f64; - println!( - "case=sparse-multi-source source_slots={SOURCE_SLOTS} present_layers={} payload_bytes={payload_bytes} encode_us_per_glyph_source_set={per_set_us:.3}", - layers.len(), - ); -} - -fn main() { - run("simple", &outline_layer("simple", 1, 24), 5_000); - run("composite", &composite_layer(), 5_000); - run_sparse_multi_source(); - run("cjk", &outline_layer("cjk", 24, 48), 500); - run( - "pathological", - &outline_layer("pathological", 1_000, 100), - 10, - ); -} diff --git a/crates/shift-glyph-codec/examples/outline_benchmark.rs b/crates/shift-glyph-codec/examples/outline_benchmark.rs deleted file mode 100644 index 344f3c80..00000000 --- a/crates/shift-glyph-codec/examples/outline_benchmark.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::{hint::black_box, time::Instant}; - -use shift_glyph_codec::{pack_outline, OutlineCommand}; - -fn representative_outline() -> Vec> { - let mut commands = vec![OutlineCommand::Move { x: 0.0, y: 0.0 }]; - for index in 0..80 { - let x = f64::from(index) * 7.25; - match index % 3 { - 0 => commands.push(OutlineCommand::Line { x, y: x * 0.5 }), - 1 => commands.push(OutlineCommand::Quad { - cx: x - 2.0, - cy: x * 0.25, - x, - y: x * 0.5, - }), - _ => commands.push(OutlineCommand::Cubic { - c1x: x - 4.0, - c1y: x * 0.2, - c2x: x - 2.0, - c2y: x * 0.4, - x, - y: x * 0.5, - }), - } - } - commands.push(OutlineCommand::Close); - commands -} - -fn main() { - const ITERATIONS: usize = 20_000; - const WARMUP_GLYPHS: usize = 400; - let commands = representative_outline(); - let packed = pack_outline(&commands).expect("representative outline should encode"); - - for _ in 0..1_000 { - black_box(pack_outline(black_box(&commands)).unwrap()); - } - - let start = Instant::now(); - for _ in 0..ITERATIONS { - black_box(pack_outline(black_box(&commands)).unwrap()); - } - let elapsed = start.elapsed(); - let per_glyph_micros = elapsed.as_secs_f64() * 1_000_000.0 / ITERATIONS as f64; - - let warmup_start = Instant::now(); - for _ in 0..WARMUP_GLYPHS { - black_box(pack_outline(black_box(&commands)).unwrap()); - } - - println!( - "payload_bytes={} commands={} encode_us_per_glyph={:.3} encode_400_ms={:.3}", - packed.as_bytes().len(), - commands.len(), - per_glyph_micros, - warmup_start.elapsed().as_secs_f64() * 1_000.0, - ); -} diff --git a/crates/shift-glyph-codec/src/frame.rs b/crates/shift-glyph-codec/src/frame.rs deleted file mode 100644 index d78c94c0..00000000 --- a/crates/shift-glyph-codec/src/frame.rs +++ /dev/null @@ -1,47 +0,0 @@ -pub(crate) const FRAME_PREFIX_LEN: usize = 8; -const MAGIC: &[u8; 4] = b"SHFT"; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum FrameError { - HeaderTruncated { actual: usize }, - WrongMagic, - UnsupportedPayloadKind(u8), - UnsupportedVersion(u8), - UnknownFlags(u16), -} - -pub(crate) fn validate_frame( - bytes: &[u8], - header_len: usize, - expected_kind: u8, - expected_version: u8, -) -> Result<(), FrameError> { - if bytes.len() < header_len { - return Err(FrameError::HeaderTruncated { - actual: bytes.len(), - }); - } - if &bytes[..4] != MAGIC { - return Err(FrameError::WrongMagic); - } - if bytes[4] != expected_kind { - return Err(FrameError::UnsupportedPayloadKind(bytes[4])); - } - if bytes[5] != expected_version { - return Err(FrameError::UnsupportedVersion(bytes[5])); - } - - let flags = u16::from_le_bytes([bytes[6], bytes[7]]); - if flags != 0 { - return Err(FrameError::UnknownFlags(flags)); - } - Ok(()) -} - -pub(crate) fn write_frame(bytes: &mut [u8], kind: u8, version: u8) { - assert!(bytes.len() >= FRAME_PREFIX_LEN); - bytes[..4].copy_from_slice(MAGIC); - bytes[4] = kind; - bytes[5] = version; - bytes[6..8].copy_from_slice(&0_u16.to_le_bytes()); -} diff --git a/crates/shift-glyph-codec/src/layer.rs b/crates/shift-glyph-codec/src/layer.rs deleted file mode 100644 index c7301106..00000000 --- a/crates/shift-glyph-codec/src/layer.rs +++ /dev/null @@ -1,1935 +0,0 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::{error::Error, fmt}; - -use crate::frame::{validate_frame, write_frame, FrameError}; -const LAYER_KIND: u8 = 0x02; -const LAYER_VERSION: u8 = 0x01; -const HEADER_LEN: usize = 72; -const NONE_STRING: u32 = u32::MAX; -const CONTOUR_LEN: usize = 12; -const POINT_LEN: usize = 24; -const COMPONENT_LEN: usize = 88; -const ANCHOR_LEN: usize = 24; -const GUIDELINE_LEN: usize = 40; - -/// Maximum contour count accepted by `shift.glyph-layer.v1`. -pub const MAX_LAYER_CONTOUR_COUNT: usize = 1_000_000; -/// Maximum point count accepted by `shift.glyph-layer.v1`. -pub const MAX_LAYER_POINT_COUNT: usize = 4_000_000; -/// Maximum count accepted for each other top-level layer entity kind. -pub const MAX_LAYER_ENTITY_COUNT: usize = 1_000_000; -/// Maximum number of distinct strings accepted by one layer payload. -pub const MAX_LAYER_STRING_COUNT: usize = 4_000_000; -/// Maximum combined UTF-8 string bytes accepted by one layer payload. -pub const MAX_LAYER_STRING_BYTES: usize = 64 * 1024 * 1024; -/// Maximum nested lib values accepted by one layer payload. -pub const MAX_LAYER_LIB_VALUES: usize = 1_000_000; -/// Maximum lib array/dictionary nesting depth. -pub const MAX_LAYER_LIB_DEPTH: usize = 64; -/// Maximum total encoded layer payload size. -pub const MAX_LAYER_PAYLOAD_BYTES: usize = 256 * 1024 * 1024; - -/// Codec-owned point semantics. Numeric discriminants are part of layer v1. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LayerPointType { - OnCurve, - OffCurve, - QCurve, -} - -impl LayerPointType { - fn byte(self) -> u8 { - match self { - Self::OnCurve => 0, - Self::OffCurve => 1, - Self::QCurve => 2, - } - } - - fn from_byte(value: u8, index: usize) -> Result { - match value { - 0 => Ok(Self::OnCurve), - 1 => Ok(Self::OffCurve), - 2 => Ok(Self::QCurve), - value => Err(LayerCodecError::UnknownPointType { index, value }), - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayerPoint { - pub id: String, - pub point_type: LayerPointType, - pub smooth: bool, - pub x: f64, - pub y: f64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayerContour { - pub id: String, - pub closed: bool, - pub points: Vec, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct LayerTransform { - pub translate_x: f64, - pub translate_y: f64, - pub rotation: f64, - pub scale_x: f64, - pub scale_y: f64, - pub skew_x: f64, - pub skew_y: f64, - pub center_x: f64, - pub center_y: f64, -} - -impl Default for LayerTransform { - fn default() -> Self { - Self { - translate_x: 0.0, - translate_y: 0.0, - rotation: 0.0, - scale_x: 1.0, - scale_y: 1.0, - skew_x: 0.0, - skew_y: 0.0, - center_x: 0.0, - center_y: 0.0, - } - } -} - -impl LayerTransform { - fn values(self) -> [f64; 9] { - [ - self.translate_x, - self.translate_y, - self.rotation, - self.scale_x, - self.scale_y, - self.skew_x, - self.skew_y, - self.center_x, - self.center_y, - ] - } - - fn from_values(values: [f64; 9]) -> Self { - Self { - translate_x: values[0], - translate_y: values[1], - rotation: values[2], - scale_x: values[3], - scale_y: values[4], - skew_x: values[5], - skew_y: values[6], - center_x: values[7], - center_y: values[8], - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayerComponent { - pub id: String, - pub base_glyph_id: String, - pub base_glyph_name: String, - pub transform: LayerTransform, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayerAnchor { - pub id: String, - pub name: Option, - pub x: f64, - pub y: f64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayerGuideline { - pub id: String, - pub x: Option, - pub y: Option, - pub angle: Option, - pub name: Option, - pub color: Option, -} - -/// Lossless values supported by authored layer lib dictionaries. -#[derive(Clone, Debug, PartialEq)] -pub enum LayerLibValue { - String(String), - Integer(i64), - UnsignedInteger(u64), - Float(f64), - Boolean(bool), - Array(Vec), - Dict(BTreeMap), - Data(Vec), - Date(String), - Uid(u64), -} - -/// Codec-owned complete authored layer input/output. -#[derive(Clone, Debug, PartialEq)] -pub struct GlyphLayer { - pub id: String, - pub source_id: String, - pub width: f64, - pub height: Option, - pub contours: Vec, - pub components: Vec, - pub anchors: Vec, - pub guidelines: Vec, - pub lib: BTreeMap, -} - -impl GlyphLayer { - pub fn empty(id: impl Into, source_id: impl Into) -> Self { - Self { - id: id.into(), - source_id: source_id.into(), - width: 0.0, - height: None, - contours: Vec::new(), - components: Vec::new(), - anchors: Vec::new(), - guidelines: Vec::new(), - lib: BTreeMap::new(), - } - } -} - -/// Strict authored-layer codec failure. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum LayerCodecError { - HeaderTruncated { - actual: usize, - }, - WrongMagic, - UnsupportedPayloadKind(u8), - UnsupportedVersion(u8), - UnknownFlags(u16), - UnknownLayerFlags(u32), - UnknownRecordFlags { - record: &'static str, - index: usize, - flags: u32, - }, - NonZeroReserved { - record: &'static str, - index: usize, - }, - UnknownPointType { - index: usize, - value: u8, - }, - UnknownLibTag { - offset: usize, - value: u8, - }, - InvalidBoolean { - offset: usize, - value: u8, - }, - InvalidUtf8 { - index: usize, - }, - InvalidStringOffsets { - index: usize, - }, - DuplicateString { - index: usize, - }, - StringReferenceOutOfRange { - index: u32, - }, - NonCanonicalStringReference { - expected_at_most: u32, - actual: u32, - }, - UnreferencedStrings { - first: u32, - }, - DuplicateIdentity { - kind: &'static str, - index: usize, - }, - NonCanonicalMapOrder { - offset: usize, - }, - NonFiniteNumber { - field: &'static str, - index: usize, - }, - NonCanonicalAbsentNumber { - field: &'static str, - index: usize, - }, - PointCountMismatch { - expected: usize, - actual: usize, - }, - LibLengthMismatch { - expected: usize, - actual: usize, - }, - NestingLimitExceeded { - limit: usize, - }, - Truncated { - offset: usize, - needed: usize, - available: usize, - }, - LengthOverflow, - LengthMismatch { - expected: usize, - actual: usize, - }, - LimitExceeded { - field: &'static str, - limit: usize, - actual: usize, - }, -} - -impl fmt::Display for LayerCodecError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::HeaderTruncated { actual } => write!(formatter, "glyph-layer header is truncated: {actual} of {HEADER_LEN} bytes"), - Self::WrongMagic => formatter.write_str("wrong glyph-codec magic"), - Self::UnsupportedPayloadKind(kind) => write!(formatter, "unsupported glyph-codec payload kind {kind:#04x}"), - Self::UnsupportedVersion(version) => write!(formatter, "unsupported glyph-layer version {version}"), - Self::UnknownFlags(flags) => write!(formatter, "unknown glyph-layer frame flags {flags:#06x}"), - Self::UnknownLayerFlags(flags) => write!(formatter, "unknown glyph-layer flags {flags:#010x}"), - Self::UnknownRecordFlags { record, index, flags } => write!(formatter, "unknown {record} flags {flags:#010x} at index {index}"), - Self::NonZeroReserved { record, index } => write!(formatter, "non-zero reserved {record} bytes at index {index}"), - Self::UnknownPointType { index, value } => write!(formatter, "unknown point type {value:#04x} at index {index}"), - Self::UnknownLibTag { offset, value } => write!(formatter, "unknown lib value tag {value:#04x} at byte {offset}"), - Self::InvalidBoolean { offset, value } => write!(formatter, "invalid boolean {value} at byte {offset}"), - Self::InvalidUtf8 { index } => write!(formatter, "invalid UTF-8 in string {index}"), - Self::InvalidStringOffsets { index } => write!(formatter, "non-canonical string offsets at index {index}"), - Self::DuplicateString { index } => write!(formatter, "duplicate string-table value at index {index}"), - Self::StringReferenceOutOfRange { index } => write!(formatter, "string reference {index} is out of range"), - Self::NonCanonicalStringReference { expected_at_most, actual } => write!(formatter, "string reference {actual} is out of canonical first-use order; expected at most {expected_at_most}"), - Self::UnreferencedStrings { first } => write!(formatter, "string table has unreferenced entries beginning at index {first}"), - Self::DuplicateIdentity { kind, index } => write!(formatter, "duplicate {kind} identity at index {index}"), - Self::NonCanonicalMapOrder { offset } => write!(formatter, "dictionary keys are not in canonical UTF-8 order at byte {offset}"), - Self::NonFiniteNumber { field, index } => write!(formatter, "non-finite {field} value at index {index}"), - Self::NonCanonicalAbsentNumber { field, index } => write!(formatter, "absent {field} value at index {index} is not canonical positive zero"), - Self::PointCountMismatch { expected, actual } => write!(formatter, "contours contain {actual} points, header declares {expected}"), - Self::LibLengthMismatch { expected, actual } => write!(formatter, "layer lib length mismatch: expected {expected} bytes, consumed {actual}"), - Self::NestingLimitExceeded { limit } => write!(formatter, "layer lib nesting exceeds implementation limit {limit}"), - Self::Truncated { offset, needed, available } => write!(formatter, "glyph-layer body is truncated at byte {offset}: need {needed} bytes, have {available}"), - Self::LengthOverflow => formatter.write_str("glyph-layer payload length arithmetic overflowed"), - Self::LengthMismatch { expected, actual } => write!(formatter, "glyph-layer payload length mismatch: expected {expected} bytes, got {actual}"), - Self::LimitExceeded { field, limit, actual } => write!(formatter, "glyph-layer {field} exceeds implementation limit {limit}: got {actual}"), - } - } -} - -impl Error for LayerCodecError {} - -impl From for LayerCodecError { - fn from(error: FrameError) -> Self { - match error { - FrameError::HeaderTruncated { actual } => Self::HeaderTruncated { actual }, - FrameError::WrongMagic => Self::WrongMagic, - FrameError::UnsupportedPayloadKind(kind) => Self::UnsupportedPayloadKind(kind), - FrameError::UnsupportedVersion(version) => Self::UnsupportedVersion(version), - FrameError::UnknownFlags(flags) => Self::UnknownFlags(flags), - } - } -} - -/// Owned canonical `shift.glyph-layer.v1` bytes. -#[derive(Clone, Debug)] -pub struct PackedGlyphLayer { - bytes: Vec, - header: LayerHeader, -} - -impl PackedGlyphLayer { - pub fn from_bytes(bytes: Vec) -> Result { - let header = LayerHeader::from_view(decode_layer(&bytes)?); - Ok(Self { bytes, header }) - } - - pub fn as_bytes(&self) -> &[u8] { - &self.bytes - } - - pub fn into_bytes(self) -> Vec { - self.bytes - } - - pub fn view(&self) -> GlyphLayerView<'_> { - self.header.view(&self.bytes) - } - - pub fn unpack(&self) -> GlyphLayer { - self.view().unpack() - } -} - -impl PartialEq for PackedGlyphLayer { - fn eq(&self, other: &Self) -> bool { - self.bytes == other.bytes - } -} - -impl Eq for PackedGlyphLayer {} - -#[derive(Clone, Copy, Debug)] -struct Strings<'a> { - bytes: &'a [u8], - count: usize, - offsets_offset: usize, - values_offset: usize, -} - -impl<'a> Strings<'a> { - fn get(self, index: u32) -> &'a str { - let index = index as usize; - debug_assert!(index < self.count); - let start = read_u32(self.bytes, self.offsets_offset + index * 4) as usize; - let end = read_u32(self.bytes, self.offsets_offset + (index + 1) * 4) as usize; - std::str::from_utf8(&self.bytes[self.values_offset + start..self.values_offset + end]) - .expect("validated UTF-8") - } -} - -#[derive(Clone, Copy, Debug)] -struct LayerHeader { - string_count: usize, - string_offsets_offset: usize, - string_values_offset: usize, - id_index: u32, - source_id_index: u32, - width: f64, - height: Option, - contour_count: usize, - point_count: usize, - component_count: usize, - anchor_count: usize, - guideline_count: usize, - contours_offset: usize, - components_offset: usize, - anchors_offset: usize, - guidelines_offset: usize, - lib_offset: usize, - lib_end: usize, -} - -impl LayerHeader { - fn from_view(view: GlyphLayerView<'_>) -> Self { - Self { - string_count: view.strings.count, - string_offsets_offset: view.strings.offsets_offset, - string_values_offset: view.strings.values_offset, - id_index: view.id_index, - source_id_index: view.source_id_index, - width: view.width, - height: view.height, - contour_count: view.contour_count, - point_count: view.point_count, - component_count: view.component_count, - anchor_count: view.anchor_count, - guideline_count: view.guideline_count, - contours_offset: view.contours_offset, - components_offset: view.components_offset, - anchors_offset: view.anchors_offset, - guidelines_offset: view.guidelines_offset, - lib_offset: view.lib_offset, - lib_end: view.lib_end, - } - } - - fn view<'a>(self, bytes: &'a [u8]) -> GlyphLayerView<'a> { - GlyphLayerView { - bytes, - strings: Strings { - bytes, - count: self.string_count, - offsets_offset: self.string_offsets_offset, - values_offset: self.string_values_offset, - }, - id_index: self.id_index, - source_id_index: self.source_id_index, - width: self.width, - height: self.height, - contour_count: self.contour_count, - point_count: self.point_count, - component_count: self.component_count, - anchor_count: self.anchor_count, - guideline_count: self.guideline_count, - contours_offset: self.contours_offset, - components_offset: self.components_offset, - anchors_offset: self.anchors_offset, - guidelines_offset: self.guidelines_offset, - lib_offset: self.lib_offset, - lib_end: self.lib_end, - } - } -} - -/// Validated allocation-free access to an authored layer payload. -#[derive(Clone, Copy, Debug)] -pub struct GlyphLayerView<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - id_index: u32, - source_id_index: u32, - width: f64, - height: Option, - contour_count: usize, - point_count: usize, - component_count: usize, - anchor_count: usize, - guideline_count: usize, - contours_offset: usize, - components_offset: usize, - anchors_offset: usize, - guidelines_offset: usize, - lib_offset: usize, - lib_end: usize, -} - -impl<'a> GlyphLayerView<'a> { - pub fn id(self) -> &'a str { - self.strings.get(self.id_index) - } - pub fn source_id(self) -> &'a str { - self.strings.get(self.source_id_index) - } - pub fn width(self) -> f64 { - self.width - } - pub fn height(self) -> Option { - self.height - } - pub fn contour_count(self) -> usize { - self.contour_count - } - pub fn point_count(self) -> usize { - self.point_count - } - pub fn component_count(self) -> usize { - self.component_count - } - pub fn anchor_count(self) -> usize { - self.anchor_count - } - pub fn guideline_count(self) -> usize { - self.guideline_count - } - - /// Streams contours; each contour streams its points directly from bytes. - pub fn contours(self) -> LayerContourIter<'a> { - LayerContourIter { - bytes: self.bytes, - strings: self.strings, - offset: self.contours_offset, - remaining: self.contour_count, - } - } - - pub fn components(self) -> LayerComponentIter<'a> { - LayerComponentIter { - bytes: self.bytes, - strings: self.strings, - offset: self.components_offset, - remaining: self.component_count, - } - } - - pub fn anchors(self) -> LayerAnchorIter<'a> { - LayerAnchorIter { - bytes: self.bytes, - strings: self.strings, - offset: self.anchors_offset, - remaining: self.anchor_count, - } - } - - pub fn guidelines(self) -> LayerGuidelineIter<'a> { - LayerGuidelineIter { - bytes: self.bytes, - strings: self.strings, - offset: self.guidelines_offset, - remaining: self.guideline_count, - } - } - - pub fn lib(self) -> BTreeMap { - let mut cursor = Cursor::with_range(self.bytes, self.lib_offset, self.lib_end); - let mut state = DecodeState::for_validated(self.strings); - decode_map(&mut cursor, &mut state, 0).expect("validated layer lib") - } - - /// Materializes exactly one editable layer, never a complete font. - pub fn unpack(self) -> GlyphLayer { - GlyphLayer { - id: self.id().to_owned(), - source_id: self.source_id().to_owned(), - width: self.width, - height: self.height, - contours: self.contours().map(LayerContourView::unpack).collect(), - components: self.components().map(LayerComponentView::unpack).collect(), - anchors: self.anchors().map(LayerAnchorView::unpack).collect(), - guidelines: self.guidelines().map(LayerGuidelineView::unpack).collect(), - lib: self.lib(), - } - } -} - -#[derive(Clone, Copy, Debug)] -pub struct LayerContourView<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - id_index: u32, - closed: bool, - point_count: usize, - points_offset: usize, -} - -impl<'a> LayerContourView<'a> { - pub fn id(self) -> &'a str { - self.strings.get(self.id_index) - } - pub fn closed(self) -> bool { - self.closed - } - pub fn point_count(self) -> usize { - self.point_count - } - pub fn points(self) -> LayerPointIter<'a> { - LayerPointIter { - bytes: self.bytes, - strings: self.strings, - offset: self.points_offset, - remaining: self.point_count, - } - } - fn unpack(self) -> LayerContour { - LayerContour { - id: self.id().to_owned(), - closed: self.closed, - points: self.points().map(LayerPointView::unpack).collect(), - } - } -} - -pub struct LayerContourIter<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - offset: usize, - remaining: usize, -} - -impl<'a> Iterator for LayerContourIter<'a> { - type Item = LayerContourView<'a>; - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let id_index = read_u32(self.bytes, self.offset); - let point_count = read_u32(self.bytes, self.offset + 4) as usize; - let closed = read_u32(self.bytes, self.offset + 8) & 1 != 0; - let points_offset = self.offset + CONTOUR_LEN; - self.offset = points_offset + point_count * POINT_LEN; - self.remaining -= 1; - Some(LayerContourView { - bytes: self.bytes, - strings: self.strings, - id_index, - closed, - point_count, - points_offset, - }) - } - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} -impl ExactSizeIterator for LayerContourIter<'_> {} - -#[derive(Clone, Copy, Debug)] -pub struct LayerPointView<'a> { - id: &'a str, - point_type: LayerPointType, - smooth: bool, - x: f64, - y: f64, -} -impl<'a> LayerPointView<'a> { - pub fn id(self) -> &'a str { - self.id - } - pub fn point_type(self) -> LayerPointType { - self.point_type - } - pub fn smooth(self) -> bool { - self.smooth - } - pub fn x(self) -> f64 { - self.x - } - pub fn y(self) -> f64 { - self.y - } - fn unpack(self) -> LayerPoint { - LayerPoint { - id: self.id.to_owned(), - point_type: self.point_type, - smooth: self.smooth, - x: self.x, - y: self.y, - } - } -} - -pub struct LayerPointIter<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - offset: usize, - remaining: usize, -} -impl<'a> Iterator for LayerPointIter<'a> { - type Item = LayerPointView<'a>; - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let index = read_u32(self.bytes, self.offset); - let point_type = match self.bytes[self.offset + 4] { - 0 => LayerPointType::OnCurve, - 1 => LayerPointType::OffCurve, - 2 => LayerPointType::QCurve, - _ => unreachable!("validated point type"), - }; - let smooth = self.bytes[self.offset + 5] & 1 != 0; - let x = read_f64(self.bytes, self.offset + 8); - let y = read_f64(self.bytes, self.offset + 16); - self.offset += POINT_LEN; - self.remaining -= 1; - Some(LayerPointView { - id: self.strings.get(index), - point_type, - smooth, - x, - y, - }) - } - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} -impl ExactSizeIterator for LayerPointIter<'_> {} - -#[derive(Clone, Copy, Debug)] -pub struct LayerComponentView<'a> { - id: &'a str, - base_glyph_id: &'a str, - base_glyph_name: &'a str, - transform: LayerTransform, -} -impl<'a> LayerComponentView<'a> { - pub fn id(self) -> &'a str { - self.id - } - pub fn base_glyph_id(self) -> &'a str { - self.base_glyph_id - } - pub fn base_glyph_name(self) -> &'a str { - self.base_glyph_name - } - pub fn transform(self) -> LayerTransform { - self.transform - } - fn unpack(self) -> LayerComponent { - LayerComponent { - id: self.id.to_owned(), - base_glyph_id: self.base_glyph_id.to_owned(), - base_glyph_name: self.base_glyph_name.to_owned(), - transform: self.transform, - } - } -} -pub struct LayerComponentIter<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - offset: usize, - remaining: usize, -} -impl<'a> Iterator for LayerComponentIter<'a> { - type Item = LayerComponentView<'a>; - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let id = self.strings.get(read_u32(self.bytes, self.offset)); - let base_glyph_id = self.strings.get(read_u32(self.bytes, self.offset + 4)); - let base_glyph_name = self.strings.get(read_u32(self.bytes, self.offset + 8)); - let mut values = [0.0; 9]; - for (value_index, value) in values.iter_mut().enumerate() { - *value = read_f64(self.bytes, self.offset + 16 + value_index * 8); - } - self.offset += COMPONENT_LEN; - self.remaining -= 1; - Some(LayerComponentView { - id, - base_glyph_id, - base_glyph_name, - transform: LayerTransform::from_values(values), - }) - } - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} -impl ExactSizeIterator for LayerComponentIter<'_> {} - -#[derive(Clone, Copy, Debug)] -pub struct LayerAnchorView<'a> { - id: &'a str, - name: Option<&'a str>, - x: f64, - y: f64, -} -impl<'a> LayerAnchorView<'a> { - pub fn id(self) -> &'a str { - self.id - } - pub fn name(self) -> Option<&'a str> { - self.name - } - pub fn x(self) -> f64 { - self.x - } - pub fn y(self) -> f64 { - self.y - } - fn unpack(self) -> LayerAnchor { - LayerAnchor { - id: self.id.to_owned(), - name: self.name.map(str::to_owned), - x: self.x, - y: self.y, - } - } -} -pub struct LayerAnchorIter<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - offset: usize, - remaining: usize, -} -impl<'a> Iterator for LayerAnchorIter<'a> { - type Item = LayerAnchorView<'a>; - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let id = self.strings.get(read_u32(self.bytes, self.offset)); - let name = optional_string(self.strings, read_u32(self.bytes, self.offset + 4)); - let x = read_f64(self.bytes, self.offset + 8); - let y = read_f64(self.bytes, self.offset + 16); - self.offset += ANCHOR_LEN; - self.remaining -= 1; - Some(LayerAnchorView { id, name, x, y }) - } - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} -impl ExactSizeIterator for LayerAnchorIter<'_> {} - -#[derive(Clone, Copy, Debug)] -pub struct LayerGuidelineView<'a> { - id: &'a str, - x: Option, - y: Option, - angle: Option, - name: Option<&'a str>, - color: Option<&'a str>, -} -impl<'a> LayerGuidelineView<'a> { - pub fn id(self) -> &'a str { - self.id - } - pub fn x(self) -> Option { - self.x - } - pub fn y(self) -> Option { - self.y - } - pub fn angle(self) -> Option { - self.angle - } - pub fn name(self) -> Option<&'a str> { - self.name - } - pub fn color(self) -> Option<&'a str> { - self.color - } - fn unpack(self) -> LayerGuideline { - LayerGuideline { - id: self.id.to_owned(), - x: self.x, - y: self.y, - angle: self.angle, - name: self.name.map(str::to_owned), - color: self.color.map(str::to_owned), - } - } -} -pub struct LayerGuidelineIter<'a> { - bytes: &'a [u8], - strings: Strings<'a>, - offset: usize, - remaining: usize, -} -impl<'a> Iterator for LayerGuidelineIter<'a> { - type Item = LayerGuidelineView<'a>; - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - let id = self.strings.get(read_u32(self.bytes, self.offset)); - let name = optional_string(self.strings, read_u32(self.bytes, self.offset + 4)); - let color = optional_string(self.strings, read_u32(self.bytes, self.offset + 8)); - let flags = read_u32(self.bytes, self.offset + 12); - let raw = [ - read_f64(self.bytes, self.offset + 16), - read_f64(self.bytes, self.offset + 24), - read_f64(self.bytes, self.offset + 32), - ]; - self.offset += GUIDELINE_LEN; - self.remaining -= 1; - Some(LayerGuidelineView { - id, - name, - color, - x: (flags & 1 != 0).then_some(raw[0]), - y: (flags & 2 != 0).then_some(raw[1]), - angle: (flags & 4 != 0).then_some(raw[2]), - }) - } - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} -impl ExactSizeIterator for LayerGuidelineIter<'_> {} - -fn optional_string<'a>(strings: Strings<'a>, index: u32) -> Option<&'a str> { - (index != NONE_STRING).then(|| strings.get(index)) -} - -fn validate_layer_input(layer: &GlyphLayer) -> Result<(), LayerCodecError> { - validate_finite(layer.width, "width", 0)?; - if let Some(height) = layer.height { - validate_finite(height, "height", 0)?; - } - - let mut contour_ids = HashSet::new(); - let mut point_ids = HashSet::new(); - let mut point_index = 0; - for (contour_index, contour) in layer.contours.iter().enumerate() { - if !contour_ids.insert(contour.id.as_str()) { - return Err(LayerCodecError::DuplicateIdentity { - kind: "contour", - index: contour_index, - }); - } - for point in &contour.points { - if !point_ids.insert(point.id.as_str()) { - return Err(LayerCodecError::DuplicateIdentity { - kind: "point", - index: point_index, - }); - } - validate_finite(point.x, "point coordinate", point_index * 2)?; - validate_finite(point.y, "point coordinate", point_index * 2 + 1)?; - point_index += 1; - } - } - - let mut component_ids = HashSet::new(); - for (index, component) in layer.components.iter().enumerate() { - if !component_ids.insert(component.id.as_str()) { - return Err(LayerCodecError::DuplicateIdentity { - kind: "component", - index, - }); - } - for (value_index, value) in component.transform.values().into_iter().enumerate() { - validate_finite(value, "component transform", index * 9 + value_index)?; - } - } - - let mut anchor_ids = HashSet::new(); - for (index, anchor) in layer.anchors.iter().enumerate() { - if !anchor_ids.insert(anchor.id.as_str()) { - return Err(LayerCodecError::DuplicateIdentity { - kind: "anchor", - index, - }); - } - validate_finite(anchor.x, "anchor coordinate", index * 2)?; - validate_finite(anchor.y, "anchor coordinate", index * 2 + 1)?; - } - - let mut guideline_ids = HashSet::new(); - for (index, guideline) in layer.guidelines.iter().enumerate() { - if !guideline_ids.insert(guideline.id.as_str()) { - return Err(LayerCodecError::DuplicateIdentity { - kind: "guideline", - index, - }); - } - for (value_index, value) in [guideline.x, guideline.y, guideline.angle] - .into_iter() - .enumerate() - { - if let Some(value) = value { - validate_finite(value, "guideline number", index * 3 + value_index)?; - } - } - } - Ok(()) -} - -/// Canonically encodes a complete authored layer. -pub fn pack_layer(layer: &GlyphLayer) -> Result { - validate_layer_input(layer)?; - check_limit( - "contour count", - layer.contours.len(), - MAX_LAYER_CONTOUR_COUNT, - )?; - check_limit( - "component count", - layer.components.len(), - MAX_LAYER_ENTITY_COUNT, - )?; - check_limit("anchor count", layer.anchors.len(), MAX_LAYER_ENTITY_COUNT)?; - check_limit( - "guideline count", - layer.guidelines.len(), - MAX_LAYER_ENTITY_COUNT, - )?; - let point_count = layer.contours.iter().try_fold(0usize, |total, contour| { - total - .checked_add(contour.points.len()) - .ok_or(LayerCodecError::LengthOverflow) - })?; - check_limit("point count", point_count, MAX_LAYER_POINT_COUNT)?; - - let string_capacity = [ - layer.contours.len(), - point_count, - layer - .components - .len() - .checked_mul(3) - .ok_or(LayerCodecError::LengthOverflow)?, - layer - .anchors - .len() - .checked_mul(2) - .ok_or(LayerCodecError::LengthOverflow)?, - layer - .guidelines - .len() - .checked_mul(3) - .ok_or(LayerCodecError::LengthOverflow)?, - ] - .into_iter() - .try_fold(2usize, |total, count| { - total - .checked_add(count) - .ok_or(LayerCodecError::LengthOverflow) - })?; - let mut strings = StringPool::with_capacity(string_capacity.min(MAX_LAYER_STRING_COUNT)); - strings.intern(&layer.id)?; - strings.intern(&layer.source_id)?; - for contour in &layer.contours { - strings.intern(&contour.id)?; - for point in &contour.points { - strings.intern(&point.id)?; - } - } - for component in &layer.components { - strings.intern(&component.id)?; - strings.intern(&component.base_glyph_id)?; - strings.intern(&component.base_glyph_name)?; - } - for anchor in &layer.anchors { - strings.intern(&anchor.id)?; - if let Some(name) = &anchor.name { - strings.intern(name)?; - } - } - for guideline in &layer.guidelines { - strings.intern(&guideline.id)?; - if let Some(name) = &guideline.name { - strings.intern(name)?; - } - if let Some(color) = &guideline.color { - strings.intern(color)?; - } - } - gather_map_strings(&layer.lib, &mut strings, 0, &mut 0)?; - - let mut bytes = vec![0; HEADER_LEN]; - let mut running_string_bytes = 0usize; - push_u32(&mut bytes, 0)?; - for value in &strings.values { - running_string_bytes = running_string_bytes - .checked_add(value.len()) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("string bytes", running_string_bytes, MAX_LAYER_STRING_BYTES)?; - push_u32(&mut bytes, running_string_bytes)?; - } - for value in &strings.values { - bytes.extend_from_slice(value.as_bytes()); - } - - let contours_offset = bytes.len(); - for contour in &layer.contours { - push_u32(&mut bytes, strings.index(&contour.id))?; - push_u32(&mut bytes, contour.points.len())?; - push_u32(&mut bytes, u32::from(contour.closed))?; - for point in &contour.points { - push_u32(&mut bytes, strings.index(&point.id))?; - bytes.push(point.point_type.byte()); - bytes.push(u8::from(point.smooth)); - bytes.extend_from_slice(&0u16.to_le_bytes()); - bytes.extend_from_slice(&point.x.to_le_bytes()); - bytes.extend_from_slice(&point.y.to_le_bytes()); - } - } - let components_offset = bytes.len(); - for component in &layer.components { - push_u32(&mut bytes, strings.index(&component.id))?; - push_u32(&mut bytes, strings.index(&component.base_glyph_id))?; - push_u32(&mut bytes, strings.index(&component.base_glyph_name))?; - push_u32(&mut bytes, 0)?; - for value in component.transform.values() { - bytes.extend_from_slice(&value.to_le_bytes()); - } - } - let anchors_offset = bytes.len(); - for anchor in &layer.anchors { - push_u32(&mut bytes, strings.index(&anchor.id))?; - push_u32( - &mut bytes, - anchor - .name - .as_ref() - .map_or(NONE_STRING, |value| strings.index(value)), - )?; - bytes.extend_from_slice(&anchor.x.to_le_bytes()); - bytes.extend_from_slice(&anchor.y.to_le_bytes()); - } - let guidelines_offset = bytes.len(); - for guideline in &layer.guidelines { - push_u32(&mut bytes, strings.index(&guideline.id))?; - push_u32( - &mut bytes, - guideline - .name - .as_ref() - .map_or(NONE_STRING, |value| strings.index(value)), - )?; - push_u32( - &mut bytes, - guideline - .color - .as_ref() - .map_or(NONE_STRING, |value| strings.index(value)), - )?; - let flags = u32::from(guideline.x.is_some()) - | (u32::from(guideline.y.is_some()) << 1) - | (u32::from(guideline.angle.is_some()) << 2); - push_u32(&mut bytes, flags)?; - for value in [guideline.x, guideline.y, guideline.angle] { - bytes.extend_from_slice(&value.unwrap_or(0.0).to_le_bytes()); - } - } - let lib_offset = bytes.len(); - encode_map(&mut bytes, &layer.lib, &strings, 0, &mut 0)?; - let lib_bytes = bytes - .len() - .checked_sub(lib_offset) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("payload byte length", bytes.len(), MAX_LAYER_PAYLOAD_BYTES)?; - - write_frame(&mut bytes, LAYER_KIND, LAYER_VERSION); - let payload_len = bytes.len(); - write_u32(&mut bytes, 8, payload_len)?; - write_u32(&mut bytes, 12, strings.values.len())?; - write_u32(&mut bytes, 16, running_string_bytes)?; - write_u32(&mut bytes, 20, layer.contours.len())?; - write_u32(&mut bytes, 24, point_count)?; - write_u32(&mut bytes, 28, layer.components.len())?; - write_u32(&mut bytes, 32, layer.anchors.len())?; - write_u32(&mut bytes, 36, layer.guidelines.len())?; - write_u32(&mut bytes, 40, lib_bytes)?; - write_u32(&mut bytes, 44, strings.index(&layer.id))?; - write_u32(&mut bytes, 48, strings.index(&layer.source_id))?; - write_u32(&mut bytes, 52, u32::from(layer.height.is_some()))?; - bytes[56..64].copy_from_slice(&layer.width.to_le_bytes()); - bytes[64..72].copy_from_slice(&layer.height.unwrap_or(0.0).to_le_bytes()); - - let string_offsets_len = strings - .values - .len() - .checked_add(1) - .and_then(|count| count.checked_mul(4)) - .ok_or(LayerCodecError::LengthOverflow)?; - let header = LayerHeader { - string_count: strings.values.len(), - string_offsets_offset: HEADER_LEN, - string_values_offset: HEADER_LEN - .checked_add(string_offsets_len) - .ok_or(LayerCodecError::LengthOverflow)?, - id_index: strings.index(&layer.id), - source_id_index: strings.index(&layer.source_id), - width: layer.width, - height: layer.height, - contour_count: layer.contours.len(), - point_count, - component_count: layer.components.len(), - anchor_count: layer.anchors.len(), - guideline_count: layer.guidelines.len(), - contours_offset, - components_offset, - anchors_offset, - guidelines_offset, - lib_offset, - lib_end: bytes.len(), - }; - debug_assert!(decode_layer(&bytes).is_ok()); - Ok(PackedGlyphLayer { bytes, header }) -} - -/// Strictly validates one canonical authored layer and returns borrowed streams. -pub fn decode_layer(bytes: &[u8]) -> Result, LayerCodecError> { - validate_frame(bytes, HEADER_LEN, LAYER_KIND, LAYER_VERSION).map_err(LayerCodecError::from)?; - check_limit("payload byte length", bytes.len(), MAX_LAYER_PAYLOAD_BYTES)?; - let declared_len = read_u32(bytes, 8) as usize; - if declared_len != bytes.len() { - return Err(LayerCodecError::LengthMismatch { - expected: declared_len, - actual: bytes.len(), - }); - } - - let string_count = read_u32(bytes, 12) as usize; - let string_bytes = read_u32(bytes, 16) as usize; - let contour_count = read_u32(bytes, 20) as usize; - let point_count = read_u32(bytes, 24) as usize; - let component_count = read_u32(bytes, 28) as usize; - let anchor_count = read_u32(bytes, 32) as usize; - let guideline_count = read_u32(bytes, 36) as usize; - let lib_bytes = read_u32(bytes, 40) as usize; - let id_index = read_u32(bytes, 44); - let source_id_index = read_u32(bytes, 48); - let layer_flags = read_u32(bytes, 52); - if layer_flags & !1 != 0 { - return Err(LayerCodecError::UnknownLayerFlags(layer_flags)); - } - let width = read_f64(bytes, 56); - validate_finite(width, "width", 0)?; - let raw_height = read_f64(bytes, 64); - let height = if layer_flags & 1 != 0 { - validate_finite(raw_height, "height", 0)?; - Some(raw_height) - } else { - validate_absent(raw_height, "height", 0)?; - None - }; - - check_limit("string count", string_count, MAX_LAYER_STRING_COUNT)?; - check_limit("string bytes", string_bytes, MAX_LAYER_STRING_BYTES)?; - check_limit("contour count", contour_count, MAX_LAYER_CONTOUR_COUNT)?; - check_limit("point count", point_count, MAX_LAYER_POINT_COUNT)?; - check_limit("component count", component_count, MAX_LAYER_ENTITY_COUNT)?; - check_limit("anchor count", anchor_count, MAX_LAYER_ENTITY_COUNT)?; - check_limit("guideline count", guideline_count, MAX_LAYER_ENTITY_COUNT)?; - - let offsets_len = string_count - .checked_add(1) - .and_then(|count| count.checked_mul(4)) - .ok_or(LayerCodecError::LengthOverflow)?; - let values_offset = HEADER_LEN - .checked_add(offsets_len) - .ok_or(LayerCodecError::LengthOverflow)?; - let strings_end = values_offset - .checked_add(string_bytes) - .ok_or(LayerCodecError::LengthOverflow)?; - require_range( - bytes, - HEADER_LEN, - offsets_len - .checked_add(string_bytes) - .ok_or(LayerCodecError::LengthOverflow)?, - )?; - let strings = Strings { - bytes, - count: string_count, - offsets_offset: HEADER_LEN, - values_offset, - }; - validate_strings(strings, string_bytes)?; - - let mut state = DecodeState::new(strings); - state.reference(id_index)?; - state.reference(source_id_index)?; - let mut cursor = Cursor::with_range(bytes, strings_end, bytes.len()); - let contours_offset = cursor.offset; - let mut actual_points = 0usize; - for contour_index in 0..contour_count { - let contour_id = state.required_string(cursor.u32()?)?; - state.unique_identity("contour", contour_index, contour_id)?; - let count = cursor.u32()? as usize; - actual_points = actual_points - .checked_add(count) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("point count", actual_points, MAX_LAYER_POINT_COUNT)?; - let flags = cursor.u32()?; - if flags & !1 != 0 { - return Err(LayerCodecError::UnknownRecordFlags { - record: "contour", - index: contour_index, - flags, - }); - } - for _ in 0..count { - let point_index = state.point_index; - let point_id = state.required_string(cursor.u32()?)?; - state.unique_identity("point", point_index, point_id)?; - let point_type = cursor.u8()?; - LayerPointType::from_byte(point_type, point_index)?; - let flags = cursor.u8()?; - if flags & !1 != 0 { - return Err(LayerCodecError::UnknownRecordFlags { - record: "point", - index: point_index, - flags: u32::from(flags), - }); - } - if cursor.u16()? != 0 { - return Err(LayerCodecError::NonZeroReserved { - record: "point", - index: point_index, - }); - } - validate_finite(cursor.f64()?, "point coordinate", point_index * 2)?; - validate_finite(cursor.f64()?, "point coordinate", point_index * 2 + 1)?; - state.point_index += 1; - } - } - if actual_points != point_count { - return Err(LayerCodecError::PointCountMismatch { - expected: point_count, - actual: actual_points, - }); - } - - let components_offset = cursor.offset; - for index in 0..component_count { - let id = state.required_string(cursor.u32()?)?; - state.unique_identity("component", index, id)?; - state.required_string(cursor.u32()?)?; - state.required_string(cursor.u32()?)?; - if cursor.u32()? != 0 { - return Err(LayerCodecError::NonZeroReserved { - record: "component", - index, - }); - } - for value_index in 0..9 { - validate_finite( - cursor.f64()?, - "component transform", - index * 9 + value_index, - )?; - } - } - let anchors_offset = cursor.offset; - for index in 0..anchor_count { - let id = state.required_string(cursor.u32()?)?; - state.unique_identity("anchor", index, id)?; - state.optional_string(cursor.u32()?)?; - validate_finite(cursor.f64()?, "anchor coordinate", index * 2)?; - validate_finite(cursor.f64()?, "anchor coordinate", index * 2 + 1)?; - } - let guidelines_offset = cursor.offset; - for index in 0..guideline_count { - let id = state.required_string(cursor.u32()?)?; - state.unique_identity("guideline", index, id)?; - state.optional_string(cursor.u32()?)?; - state.optional_string(cursor.u32()?)?; - let flags = cursor.u32()?; - if flags & !7 != 0 { - return Err(LayerCodecError::UnknownRecordFlags { - record: "guideline", - index, - flags, - }); - } - for (value_index, bit) in [1, 2, 4].into_iter().enumerate() { - let value = cursor.f64()?; - if flags & bit != 0 { - validate_finite(value, "guideline number", index * 3 + value_index)?; - } else { - validate_absent(value, "guideline number", index * 3 + value_index)?; - } - } - } - let lib_offset = cursor.offset; - let lib_end = lib_offset - .checked_add(lib_bytes) - .ok_or(LayerCodecError::LengthOverflow)?; - if lib_end > bytes.len() { - return Err(LayerCodecError::Truncated { - offset: lib_offset, - needed: lib_bytes, - available: bytes.len().saturating_sub(lib_offset), - }); - } - cursor.end = lib_end; - decode_map(&mut cursor, &mut state, 0)?; - let consumed_lib = cursor.offset - lib_offset; - if consumed_lib != lib_bytes { - return Err(LayerCodecError::LibLengthMismatch { - expected: lib_bytes, - actual: consumed_lib, - }); - } - if lib_end != bytes.len() { - return Err(LayerCodecError::LengthMismatch { - expected: lib_end, - actual: bytes.len(), - }); - } - if state.next_string != string_count as u32 { - return Err(LayerCodecError::UnreferencedStrings { - first: state.next_string, - }); - } - - Ok(GlyphLayerView { - bytes, - strings, - id_index, - source_id_index, - width, - height, - contour_count, - point_count, - component_count, - anchor_count, - guideline_count, - contours_offset, - components_offset, - anchors_offset, - guidelines_offset, - lib_offset, - lib_end, - }) -} - -#[derive(Default)] -struct StringPool<'a> { - values: Vec<&'a str>, - indexes: HashMap<&'a str, u32>, -} -impl<'a> StringPool<'a> { - fn with_capacity(capacity: usize) -> Self { - Self { - values: Vec::with_capacity(capacity), - indexes: HashMap::with_capacity(capacity), - } - } - - fn intern(&mut self, value: &'a str) -> Result { - if let Some(index) = self.indexes.get(value) { - return Ok(*index); - } - check_limit( - "string count", - self.values.len() + 1, - MAX_LAYER_STRING_COUNT, - )?; - let index = - u32::try_from(self.values.len()).map_err(|_| LayerCodecError::LengthOverflow)?; - self.values.push(value); - self.indexes.insert(value, index); - Ok(index) - } - fn index(&self, value: &str) -> u32 { - *self - .indexes - .get(value) - .expect("strings gathered before encoding") - } -} - -struct DecodeState<'a> { - strings: Strings<'a>, - next_string: u32, - identities: HashMap<&'static str, HashSet<&'a str>>, - point_index: usize, - lib_values: usize, - validate_references: bool, -} -impl<'a> DecodeState<'a> { - fn new(strings: Strings<'a>) -> Self { - Self { - strings, - next_string: 0, - identities: HashMap::new(), - point_index: 0, - lib_values: 0, - validate_references: true, - } - } - fn for_validated(strings: Strings<'a>) -> Self { - Self { - validate_references: false, - ..Self::new(strings) - } - } - fn reference(&mut self, index: u32) -> Result<(), LayerCodecError> { - if index as usize >= self.strings.count { - return Err(LayerCodecError::StringReferenceOutOfRange { index }); - } - if self.validate_references { - if index > self.next_string { - return Err(LayerCodecError::NonCanonicalStringReference { - expected_at_most: self.next_string, - actual: index, - }); - } - if index == self.next_string { - self.next_string += 1; - } - } - Ok(()) - } - fn required_string(&mut self, index: u32) -> Result<&'a str, LayerCodecError> { - self.reference(index)?; - Ok(self.strings.get(index)) - } - fn optional_string(&mut self, index: u32) -> Result, LayerCodecError> { - if index == NONE_STRING { - Ok(None) - } else { - self.required_string(index).map(Some) - } - } - fn unique_identity( - &mut self, - kind: &'static str, - index: usize, - value: &'a str, - ) -> Result<(), LayerCodecError> { - if !self.identities.entry(kind).or_default().insert(value) { - return Err(LayerCodecError::DuplicateIdentity { kind, index }); - } - Ok(()) - } - fn count_lib_value(&mut self) -> Result<(), LayerCodecError> { - self.lib_values = self - .lib_values - .checked_add(1) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("lib value count", self.lib_values, MAX_LAYER_LIB_VALUES) - } -} - -#[derive(Clone, Copy)] -struct Cursor<'a> { - bytes: &'a [u8], - offset: usize, - end: usize, -} -impl<'a> Cursor<'a> { - fn with_range(bytes: &'a [u8], offset: usize, end: usize) -> Self { - Self { bytes, offset, end } - } - fn take(&mut self, count: usize) -> Result<&'a [u8], LayerCodecError> { - let next = self - .offset - .checked_add(count) - .ok_or(LayerCodecError::LengthOverflow)?; - if next > self.end { - return Err(LayerCodecError::Truncated { - offset: self.offset, - needed: count, - available: self.end.saturating_sub(self.offset), - }); - } - let value = &self.bytes[self.offset..next]; - self.offset = next; - Ok(value) - } - fn u8(&mut self) -> Result { - Ok(self.take(1)?[0]) - } - fn u16(&mut self) -> Result { - Ok(u16::from_le_bytes( - self.take(2)?.try_into().expect("fixed width"), - )) - } - fn u32(&mut self) -> Result { - Ok(u32::from_le_bytes( - self.take(4)?.try_into().expect("fixed width"), - )) - } - fn i64(&mut self) -> Result { - Ok(i64::from_le_bytes( - self.take(8)?.try_into().expect("fixed width"), - )) - } - fn u64(&mut self) -> Result { - Ok(u64::from_le_bytes( - self.take(8)?.try_into().expect("fixed width"), - )) - } - fn f64(&mut self) -> Result { - Ok(f64::from_le_bytes( - self.take(8)?.try_into().expect("fixed width"), - )) - } -} - -fn decode_map( - cursor: &mut Cursor<'_>, - state: &mut DecodeState<'_>, - depth: usize, -) -> Result, LayerCodecError> { - check_depth(depth)?; - let count = cursor.u32()? as usize; - check_limit( - "lib value count", - state.lib_values.saturating_add(count), - MAX_LAYER_LIB_VALUES, - )?; - let mut values = BTreeMap::new(); - let mut previous: Option<&str> = None; - for _ in 0..count { - let key_offset = cursor.offset; - let key = state.required_string(cursor.u32()?)?; - if previous.is_some_and(|value| value.as_bytes() >= key.as_bytes()) { - return Err(LayerCodecError::NonCanonicalMapOrder { offset: key_offset }); - } - previous = Some(key); - let value = decode_lib_value(cursor, state, depth + 1)?; - values.insert(key.to_owned(), value); - } - Ok(values) -} - -fn decode_lib_value( - cursor: &mut Cursor<'_>, - state: &mut DecodeState<'_>, - depth: usize, -) -> Result { - check_depth(depth)?; - state.count_lib_value()?; - let tag_offset = cursor.offset; - let tag = cursor.u8()?; - if cursor.u8()? != 0 || cursor.u16()? != 0 { - return Err(LayerCodecError::NonZeroReserved { - record: "lib value", - index: state.lib_values - 1, - }); - } - match tag { - 0 => Ok(LayerLibValue::String( - state.required_string(cursor.u32()?)?.to_owned(), - )), - 1 => Ok(LayerLibValue::Integer(cursor.i64()?)), - 2 => Ok(LayerLibValue::UnsignedInteger(cursor.u64()?)), - 3 => { - let value = cursor.f64()?; - validate_finite(value, "lib float", state.lib_values - 1)?; - Ok(LayerLibValue::Float(value)) - } - 4 => { - let offset = cursor.offset; - let value = cursor.u8()?; - if cursor.take(3)? != [0, 0, 0] { - return Err(LayerCodecError::NonZeroReserved { - record: "lib boolean", - index: state.lib_values - 1, - }); - } - match value { - 0 => Ok(LayerLibValue::Boolean(false)), - 1 => Ok(LayerLibValue::Boolean(true)), - value => Err(LayerCodecError::InvalidBoolean { offset, value }), - } - } - 5 => { - let count = cursor.u32()? as usize; - check_limit( - "lib value count", - state.lib_values.saturating_add(count), - MAX_LAYER_LIB_VALUES, - )?; - let minimum_encoded_value_bytes = 4; - let verified_capacity = cursor - .end - .saturating_sub(cursor.offset) - .checked_div(minimum_encoded_value_bytes) - .unwrap_or_default(); - let mut values = Vec::with_capacity(count.min(verified_capacity)); - for _ in 0..count { - values.push(decode_lib_value(cursor, state, depth + 1)?); - } - Ok(LayerLibValue::Array(values)) - } - 6 => Ok(LayerLibValue::Dict(decode_map(cursor, state, depth + 1)?)), - 7 => { - let count = cursor.u32()? as usize; - Ok(LayerLibValue::Data(cursor.take(count)?.to_vec())) - } - 8 => Ok(LayerLibValue::Date( - state.required_string(cursor.u32()?)?.to_owned(), - )), - 9 => Ok(LayerLibValue::Uid(cursor.u64()?)), - value => Err(LayerCodecError::UnknownLibTag { - offset: tag_offset, - value, - }), - } -} - -fn gather_map_strings<'a>( - values: &'a BTreeMap, - strings: &mut StringPool<'a>, - depth: usize, - count: &mut usize, -) -> Result<(), LayerCodecError> { - check_depth(depth)?; - // BTreeMap's String order is the same lexicographic order as UTF-8 bytes. - for (key, value) in values { - strings.intern(key)?; - gather_value_strings(value, strings, depth + 1, count)?; - } - Ok(()) -} -fn gather_value_strings<'a>( - value: &'a LayerLibValue, - strings: &mut StringPool<'a>, - depth: usize, - count: &mut usize, -) -> Result<(), LayerCodecError> { - check_depth(depth)?; - *count = count - .checked_add(1) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("lib value count", *count, MAX_LAYER_LIB_VALUES)?; - match value { - LayerLibValue::String(value) | LayerLibValue::Date(value) => { - strings.intern(value)?; - } - LayerLibValue::Array(values) => { - for value in values { - gather_value_strings(value, strings, depth + 1, count)?; - } - } - LayerLibValue::Dict(values) => gather_map_strings(values, strings, depth + 1, count)?, - LayerLibValue::Float(value) => validate_finite(*value, "lib float", *count - 1)?, - LayerLibValue::Integer(_) - | LayerLibValue::UnsignedInteger(_) - | LayerLibValue::Boolean(_) - | LayerLibValue::Data(_) - | LayerLibValue::Uid(_) => {} - } - Ok(()) -} - -fn encode_map( - bytes: &mut Vec, - values: &BTreeMap, - strings: &StringPool<'_>, - depth: usize, - count: &mut usize, -) -> Result<(), LayerCodecError> { - check_depth(depth)?; - push_u32(bytes, values.len())?; - for (key, value) in values { - push_u32(bytes, strings.index(key))?; - encode_lib_value(bytes, value, strings, depth + 1, count)?; - } - Ok(()) -} -fn encode_lib_value( - bytes: &mut Vec, - value: &LayerLibValue, - strings: &StringPool<'_>, - depth: usize, - count: &mut usize, -) -> Result<(), LayerCodecError> { - check_depth(depth)?; - *count = count - .checked_add(1) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("lib value count", *count, MAX_LAYER_LIB_VALUES)?; - let tag = match value { - LayerLibValue::String(_) => 0, - LayerLibValue::Integer(_) => 1, - LayerLibValue::UnsignedInteger(_) => 2, - LayerLibValue::Float(_) => 3, - LayerLibValue::Boolean(_) => 4, - LayerLibValue::Array(_) => 5, - LayerLibValue::Dict(_) => 6, - LayerLibValue::Data(_) => 7, - LayerLibValue::Date(_) => 8, - LayerLibValue::Uid(_) => 9, - }; - bytes.extend_from_slice(&[tag, 0, 0, 0]); - match value { - LayerLibValue::String(value) | LayerLibValue::Date(value) => { - push_u32(bytes, strings.index(value))? - } - LayerLibValue::Integer(value) => bytes.extend_from_slice(&value.to_le_bytes()), - LayerLibValue::UnsignedInteger(value) | LayerLibValue::Uid(value) => { - bytes.extend_from_slice(&value.to_le_bytes()) - } - LayerLibValue::Float(value) => bytes.extend_from_slice(&value.to_le_bytes()), - LayerLibValue::Boolean(value) => bytes.extend_from_slice(&[u8::from(*value), 0, 0, 0]), - LayerLibValue::Array(values) => { - push_u32(bytes, values.len())?; - for value in values { - encode_lib_value(bytes, value, strings, depth + 1, count)?; - } - } - LayerLibValue::Dict(values) => encode_map(bytes, values, strings, depth + 1, count)?, - LayerLibValue::Data(value) => { - push_u32(bytes, value.len())?; - let payload_len = bytes - .len() - .checked_add(value.len()) - .ok_or(LayerCodecError::LengthOverflow)?; - check_limit("payload byte length", payload_len, MAX_LAYER_PAYLOAD_BYTES)?; - bytes.extend_from_slice(value); - } - } - Ok(()) -} - -fn validate_strings(strings: Strings<'_>, expected_bytes: usize) -> Result<(), LayerCodecError> { - if read_u32(strings.bytes, strings.offsets_offset) != 0 { - return Err(LayerCodecError::InvalidStringOffsets { index: 0 }); - } - let mut previous = 0usize; - let mut seen = HashSet::with_capacity(strings.count); - for index in 0..strings.count { - let end = read_u32(strings.bytes, strings.offsets_offset + (index + 1) * 4) as usize; - if end < previous || end > expected_bytes { - return Err(LayerCodecError::InvalidStringOffsets { index: index + 1 }); - } - let value = std::str::from_utf8( - &strings.bytes[strings.values_offset + previous..strings.values_offset + end], - ) - .map_err(|_| LayerCodecError::InvalidUtf8 { index })?; - if !seen.insert(value) { - return Err(LayerCodecError::DuplicateString { index }); - } - previous = end; - } - if previous != expected_bytes { - return Err(LayerCodecError::InvalidStringOffsets { - index: strings.count, - }); - } - Ok(()) -} - -fn validate_finite(value: f64, field: &'static str, index: usize) -> Result<(), LayerCodecError> { - if value.is_finite() { - Ok(()) - } else { - Err(LayerCodecError::NonFiniteNumber { field, index }) - } -} -fn validate_absent(value: f64, field: &'static str, index: usize) -> Result<(), LayerCodecError> { - if value.to_bits() == 0 { - Ok(()) - } else { - Err(LayerCodecError::NonCanonicalAbsentNumber { field, index }) - } -} -fn check_depth(depth: usize) -> Result<(), LayerCodecError> { - if depth > MAX_LAYER_LIB_DEPTH { - Err(LayerCodecError::NestingLimitExceeded { - limit: MAX_LAYER_LIB_DEPTH, - }) - } else { - Ok(()) - } -} -fn check_limit(field: &'static str, actual: usize, limit: usize) -> Result<(), LayerCodecError> { - if actual > limit { - Err(LayerCodecError::LimitExceeded { - field, - limit, - actual, - }) - } else { - Ok(()) - } -} -fn require_range(bytes: &[u8], offset: usize, count: usize) -> Result<(), LayerCodecError> { - let end = offset - .checked_add(count) - .ok_or(LayerCodecError::LengthOverflow)?; - if end > bytes.len() { - Err(LayerCodecError::Truncated { - offset, - needed: count, - available: bytes.len().saturating_sub(offset), - }) - } else { - Ok(()) - } -} -fn push_u32(bytes: &mut Vec, value: T) -> Result<(), LayerCodecError> -where - T: TryInto, -{ - let value = value - .try_into() - .ok() - .ok_or(LayerCodecError::LengthOverflow)?; - bytes.extend_from_slice(&value.to_le_bytes()); - Ok(()) -} -fn write_u32(bytes: &mut [u8], offset: usize, value: T) -> Result<(), LayerCodecError> -where - T: TryInto, -{ - let value = value - .try_into() - .ok() - .ok_or(LayerCodecError::LengthOverflow)?; - bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); - Ok(()) -} -fn read_u32(bytes: &[u8], offset: usize) -> u32 { - u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) -} -fn read_f64(bytes: &[u8], offset: usize) -> f64 { - f64::from_le_bytes(bytes[offset..offset + 8].try_into().expect("fixed width")) -} diff --git a/crates/shift-glyph-codec/src/lib.rs b/crates/shift-glyph-codec/src/lib.rs deleted file mode 100644 index a2f3b232..00000000 --- a/crates/shift-glyph-codec/src/lib.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Packed glyph payload codecs. -//! -//! This crate owns byte framing and strict validation. It intentionally has no -//! dependency on `shift-font`: authored geometry adaptation belongs to the -//! domain/transport boundary, not to the codec. - -mod frame; -mod layer; -mod outline; - -pub use layer::{ - decode_layer, pack_layer, GlyphLayer, GlyphLayerView, LayerAnchor, LayerAnchorIter, - LayerAnchorView, LayerCodecError, LayerComponent, LayerComponentIter, LayerComponentView, - LayerContour, LayerContourIter, LayerContourView, LayerGuideline, LayerGuidelineIter, - LayerGuidelineView, LayerLibValue, LayerPoint, LayerPointIter, LayerPointType, LayerPointView, - LayerTransform, PackedGlyphLayer, MAX_LAYER_CONTOUR_COUNT, MAX_LAYER_ENTITY_COUNT, - MAX_LAYER_LIB_DEPTH, MAX_LAYER_LIB_VALUES, MAX_LAYER_PAYLOAD_BYTES, MAX_LAYER_POINT_COUNT, - MAX_LAYER_STRING_BYTES, MAX_LAYER_STRING_COUNT, -}; -pub use outline::{ - decode_outline, pack_outline, CodecError, CommandOrderError, CoordinateIter, OutlineCommand, - OutlineCommandIter, OutlineEncoder, OutlineView, PackedGlyphOutline, MAX_COMMAND_COUNT, - MAX_COORD_COUNT, MAX_PAYLOAD_BYTES, -}; diff --git a/crates/shift-glyph-codec/src/outline.rs b/crates/shift-glyph-codec/src/outline.rs deleted file mode 100644 index 521f99ac..00000000 --- a/crates/shift-glyph-codec/src/outline.rs +++ /dev/null @@ -1,664 +0,0 @@ -use std::{error::Error, fmt}; - -use crate::frame::{validate_frame, write_frame, FrameError}; -const OUTLINE_KIND: u8 = 0x01; -const OUTLINE_VERSION: u8 = 0x01; -const HEADER_LEN: usize = 16; - -/// Maximum command count accepted by the v1 implementation. -pub const MAX_COMMAND_COUNT: usize = 1_000_000; -/// Maximum coordinate count accepted by the v1 implementation. -pub const MAX_COORD_COUNT: usize = 6_000_000; -/// Maximum total payload size accepted by the v1 implementation. -pub const MAX_PAYLOAD_BYTES: usize = 32 * 1024 * 1024; - -/// One codec-owned drawing command. -/// -/// Encoders accept `OutlineCommand` and perform the specified finite f64 -/// to f32 conversion. Decoded iterators yield `OutlineCommand` directly -/// from the validated payload. -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum OutlineCommand { - Move { - x: T, - y: T, - }, - Line { - x: T, - y: T, - }, - Quad { - cx: T, - cy: T, - x: T, - y: T, - }, - Cubic { - c1x: T, - c1y: T, - c2x: T, - c2y: T, - x: T, - y: T, - }, - Close, -} - -impl OutlineCommand { - fn byte(&self) -> u8 { - match self { - Self::Move { .. } => 0, - Self::Line { .. } => 1, - Self::Quad { .. } => 2, - Self::Cubic { .. } => 3, - Self::Close => 4, - } - } - - fn arity(&self) -> usize { - match self { - Self::Move { .. } | Self::Line { .. } => 2, - Self::Quad { .. } => 4, - Self::Cubic { .. } => 6, - Self::Close => 0, - } - } -} - -/// The command-stream rule violated by an encoder or decoder. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CommandOrderError { - DrawingCommandWithoutContour, - CloseWithoutContour, - CloseWithoutDrawingSegment, -} - -impl fmt::Display for CommandOrderError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::DrawingCommandWithoutContour => { - f.write_str("drawing command requires an active contour") - } - Self::CloseWithoutContour => f.write_str("close requires an active contour"), - Self::CloseWithoutDrawingSegment => { - f.write_str("close requires at least one drawing segment") - } - } - } -} - -/// Strict outline codec failure. -#[derive(Debug, Eq, PartialEq)] -pub enum CodecError { - HeaderTruncated { - actual: usize, - }, - WrongMagic, - UnsupportedPayloadKind(u8), - UnsupportedVersion(u8), - UnknownFlags(u16), - UnknownCommand { - index: usize, - command: u8, - }, - InvalidCommandOrder { - index: usize, - reason: CommandOrderError, - }, - CoordinateCountMismatch { - expected: usize, - actual: usize, - }, - NonZeroPadding { - offset: usize, - }, - NonFiniteCoordinate { - index: usize, - }, - NonFiniteInputCoordinate { - index: usize, - }, - CoordinateOutOfF32Range { - index: usize, - }, - LengthOverflow, - LengthMismatch { - expected: usize, - actual: usize, - }, - LimitExceeded { - field: &'static str, - limit: usize, - actual: usize, - }, -} - -impl fmt::Display for CodecError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::HeaderTruncated { actual } => { - write!( - f, - "outline header is truncated: {actual} of {HEADER_LEN} bytes" - ) - } - Self::WrongMagic => f.write_str("wrong glyph-codec magic"), - Self::UnsupportedPayloadKind(kind) => { - write!(f, "unsupported glyph-codec payload kind {kind:#04x}") - } - Self::UnsupportedVersion(version) => { - write!(f, "unsupported glyph-outline version {version}") - } - Self::UnknownFlags(flags) => write!(f, "unknown glyph-outline flags {flags:#06x}"), - Self::UnknownCommand { index, command } => { - write!(f, "unknown outline command {command:#04x} at index {index}") - } - Self::InvalidCommandOrder { index, reason } => { - write!( - f, - "invalid outline command ordering at index {index}: {reason}" - ) - } - Self::CoordinateCountMismatch { expected, actual } => write!( - f, - "outline command arity requires {expected} coordinates, header declares {actual}" - ), - Self::NonZeroPadding { offset } => { - write!(f, "non-zero outline alignment padding at byte {offset}") - } - Self::NonFiniteCoordinate { index } => { - write!(f, "non-finite outline coordinate at index {index}") - } - Self::NonFiniteInputCoordinate { index } => { - write!(f, "non-finite input coordinate at index {index}") - } - Self::CoordinateOutOfF32Range { index } => { - write!( - f, - "input coordinate at index {index} is outside finite f32 range" - ) - } - Self::LengthOverflow => f.write_str("outline payload length arithmetic overflowed"), - Self::LengthMismatch { expected, actual } => write!( - f, - "outline payload length mismatch: expected {expected} bytes, got {actual}" - ), - Self::LimitExceeded { - field, - limit, - actual, - } => write!( - f, - "outline {field} exceeds implementation limit {limit}: got {actual}" - ), - } - } -} - -impl Error for CodecError {} - -impl From for CodecError { - fn from(error: FrameError) -> Self { - match error { - FrameError::HeaderTruncated { actual } => Self::HeaderTruncated { actual }, - FrameError::WrongMagic => Self::WrongMagic, - FrameError::UnsupportedPayloadKind(kind) => Self::UnsupportedPayloadKind(kind), - FrameError::UnsupportedVersion(version) => Self::UnsupportedVersion(version), - FrameError::UnknownFlags(flags) => Self::UnknownFlags(flags), - } - } -} - -/// An owned, canonical `shift.glyph-outline.v1` payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct PackedGlyphOutline(Vec); - -impl PackedGlyphOutline { - /// Validates and takes ownership of an existing payload. - pub fn from_bytes(bytes: Vec) -> Result { - decode_outline(&bytes)?; - Ok(Self(bytes)) - } - - /// Returns the canonical encoded bytes. - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - - /// Consumes the opaque value and returns its canonical bytes. - pub fn into_bytes(self) -> Vec { - self.0 - } - - /// Returns a validated, allocation-free view over this payload. - pub fn view(&self) -> OutlineView<'_> { - // Construction and decoding are the only ways to create this type. - decode_outline(&self.0).expect("PackedGlyphOutline invariant violated") - } -} - -/// Incremental canonical encoder used by domain adapters to avoid allocating an -/// intermediate command-object graph. -#[derive(Debug, Default)] -pub struct OutlineEncoder { - commands: Vec, - coordinates: Vec, - state: CommandState, -} - -impl OutlineEncoder { - pub fn new() -> Self { - Self::default() - } - - /// Creates an encoder with checked capacities. - pub fn with_capacity( - command_count: usize, - coordinate_count: usize, - ) -> Result { - check_limit("command count", command_count, MAX_COMMAND_COUNT)?; - check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; - - Ok(Self { - commands: Vec::with_capacity(command_count), - coordinates: Vec::with_capacity(coordinate_count), - state: CommandState::default(), - }) - } - - /// Adds one command after validating ordering and every converted coordinate. - pub fn push(&mut self, command: OutlineCommand) -> Result<(), CodecError> { - let command_index = self.commands.len(); - check_limit( - "command count", - command_index - .checked_add(1) - .ok_or(CodecError::LengthOverflow)?, - MAX_COMMAND_COUNT, - )?; - let mut next_state = self.state; - validate_order(&mut next_state, command.byte(), command_index)?; - - let coordinate_index = self.coordinates.len(); - let arity = command.arity(); - check_limit( - "coordinate count", - coordinate_index - .checked_add(arity) - .ok_or(CodecError::LengthOverflow)?, - MAX_COORD_COUNT, - )?; - - let mut converted = [0.0_f32; 6]; - let count = match command { - OutlineCommand::Move { x, y } | OutlineCommand::Line { x, y } => { - converted[0] = convert_coordinate(x, coordinate_index)?; - converted[1] = convert_coordinate(y, coordinate_index + 1)?; - 2 - } - OutlineCommand::Quad { cx, cy, x, y } => { - for (slot, (value, index)) in converted.iter_mut().zip( - [cx, cy, x, y] - .into_iter() - .zip(coordinate_index..coordinate_index + 4), - ) { - *slot = convert_coordinate(value, index)?; - } - 4 - } - OutlineCommand::Cubic { - c1x, - c1y, - c2x, - c2y, - x, - y, - } => { - for (slot, (value, index)) in converted.iter_mut().zip( - [c1x, c1y, c2x, c2y, x, y] - .into_iter() - .zip(coordinate_index..coordinate_index + 6), - ) { - *slot = convert_coordinate(value, index)?; - } - 6 - } - OutlineCommand::Close => 0, - }; - - self.state = next_state; - self.commands.push(command.byte()); - self.coordinates.extend_from_slice(&converted[..count]); - Ok(()) - } - - /// Finishes the payload with canonical zero padding and little-endian f32s. - pub fn finish(self) -> Result { - let command_count = self.commands.len(); - let coordinate_count = self.coordinates.len(); - let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; - let coordinate_bytes = coordinate_count - .checked_mul(4) - .ok_or(CodecError::LengthOverflow)?; - let total_len = HEADER_LEN - .checked_add(aligned_commands) - .and_then(|value| value.checked_add(coordinate_bytes)) - .ok_or(CodecError::LengthOverflow)?; - check_limit("payload byte length", total_len, MAX_PAYLOAD_BYTES)?; - - let command_count = u32::try_from(command_count).map_err(|_| CodecError::LengthOverflow)?; - let coordinate_count = - u32::try_from(coordinate_count).map_err(|_| CodecError::LengthOverflow)?; - let mut bytes = vec![0; HEADER_LEN]; - write_frame(&mut bytes, OUTLINE_KIND, OUTLINE_VERSION); - bytes[8..12].copy_from_slice(&command_count.to_le_bytes()); - bytes[12..16].copy_from_slice(&coordinate_count.to_le_bytes()); - bytes.extend_from_slice(&self.commands); - bytes.resize(HEADER_LEN + aligned_commands, 0); - for coordinate in self.coordinates { - bytes.extend_from_slice(&coordinate.to_le_bytes()); - } - - debug_assert_eq!(bytes.len(), total_len); - Ok(PackedGlyphOutline(bytes)) - } -} - -/// Canonically encodes a codec-owned command slice. -pub fn pack_outline(commands: &[OutlineCommand]) -> Result { - let coordinate_count = commands.iter().try_fold(0_usize, |count, command| { - count - .checked_add(command.arity()) - .ok_or(CodecError::LengthOverflow) - })?; - let mut encoder = OutlineEncoder::with_capacity(commands.len(), coordinate_count)?; - for command in commands { - encoder.push(*command)?; - } - encoder.finish() -} - -/// A validated, borrowed outline payload view. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct OutlineView<'a> { - bytes: &'a [u8], - command_count: usize, - coordinate_count: usize, - coordinate_offset: usize, -} - -impl<'a> OutlineView<'a> { - pub fn command_count(&self) -> usize { - self.command_count - } - - pub fn coordinate_count(&self) -> usize { - self.coordinate_count - } - - /// Iterates decoded commands without allocating points or offset tables. - pub fn commands(&self) -> OutlineCommandIter<'a> { - OutlineCommandIter { - bytes: self.bytes, - command_count: self.command_count, - command_index: 0, - coordinate_offset: self.coordinate_offset, - } - } - - /// Iterates all coordinates in command-stream order. - pub fn coordinates(&self) -> CoordinateIter<'a> { - CoordinateIter { - bytes: self.bytes, - offset: self.coordinate_offset, - remaining: self.coordinate_count, - } - } -} - -/// Strictly validates one outline buffer and returns an allocation-free view. -pub fn decode_outline(bytes: &[u8]) -> Result, CodecError> { - validate_frame(bytes, HEADER_LEN, OUTLINE_KIND, OUTLINE_VERSION).map_err(CodecError::from)?; - check_limit("payload byte length", bytes.len(), MAX_PAYLOAD_BYTES)?; - - let command_count = read_u32(bytes, 8) as usize; - let coordinate_count = read_u32(bytes, 12) as usize; - check_limit("command count", command_count, MAX_COMMAND_COUNT)?; - check_limit("coordinate count", coordinate_count, MAX_COORD_COUNT)?; - - let aligned_commands = align4(command_count).ok_or(CodecError::LengthOverflow)?; - let coordinate_bytes = coordinate_count - .checked_mul(4) - .ok_or(CodecError::LengthOverflow)?; - let expected_len = HEADER_LEN - .checked_add(aligned_commands) - .and_then(|value| value.checked_add(coordinate_bytes)) - .ok_or(CodecError::LengthOverflow)?; - check_limit("payload byte length", expected_len, MAX_PAYLOAD_BYTES)?; - if bytes.len() != expected_len { - return Err(CodecError::LengthMismatch { - expected: expected_len, - actual: bytes.len(), - }); - } - - let commands_end = HEADER_LEN + command_count; - let mut state = CommandState::default(); - let mut expected_coordinate_count = 0_usize; - for (index, command) in bytes[HEADER_LEN..commands_end].iter().copied().enumerate() { - let arity = command_arity(command).ok_or(CodecError::UnknownCommand { index, command })?; - validate_order(&mut state, command, index)?; - expected_coordinate_count = expected_coordinate_count - .checked_add(arity) - .ok_or(CodecError::LengthOverflow)?; - } - if expected_coordinate_count != coordinate_count { - return Err(CodecError::CoordinateCountMismatch { - expected: expected_coordinate_count, - actual: coordinate_count, - }); - } - - let coordinate_offset = HEADER_LEN + aligned_commands; - for (relative_offset, byte) in bytes[commands_end..coordinate_offset] - .iter() - .copied() - .enumerate() - { - if byte != 0 { - return Err(CodecError::NonZeroPadding { - offset: commands_end + relative_offset, - }); - } - } - - for index in 0..coordinate_count { - let coordinate = read_f32(bytes, coordinate_offset + index * 4); - if !coordinate.is_finite() { - return Err(CodecError::NonFiniteCoordinate { index }); - } - } - - Ok(OutlineView { - bytes, - command_count, - coordinate_count, - coordinate_offset, - }) -} - -/// Iterator over commands in a validated outline view. -pub struct OutlineCommandIter<'a> { - bytes: &'a [u8], - command_count: usize, - command_index: usize, - coordinate_offset: usize, -} - -impl Iterator for OutlineCommandIter<'_> { - type Item = OutlineCommand; - - fn next(&mut self) -> Option { - if self.command_index >= self.command_count { - return None; - } - - let command = self.bytes[HEADER_LEN + self.command_index]; - self.command_index += 1; - let mut next = || { - let value = read_f32(self.bytes, self.coordinate_offset); - self.coordinate_offset += 4; - value - }; - Some(match command { - 0 => OutlineCommand::Move { - x: next(), - y: next(), - }, - 1 => OutlineCommand::Line { - x: next(), - y: next(), - }, - 2 => OutlineCommand::Quad { - cx: next(), - cy: next(), - x: next(), - y: next(), - }, - 3 => OutlineCommand::Cubic { - c1x: next(), - c1y: next(), - c2x: next(), - c2y: next(), - x: next(), - y: next(), - }, - 4 => OutlineCommand::Close, - _ => unreachable!("validated command byte"), - }) - } - - fn size_hint(&self) -> (usize, Option) { - let remaining = self.command_count - self.command_index; - (remaining, Some(remaining)) - } -} - -impl ExactSizeIterator for OutlineCommandIter<'_> {} - -/// Iterator over f32 coordinates in a validated outline view. -pub struct CoordinateIter<'a> { - bytes: &'a [u8], - offset: usize, - remaining: usize, -} - -impl Iterator for CoordinateIter<'_> { - type Item = f32; - - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } - - let value = read_f32(self.bytes, self.offset); - self.offset += 4; - self.remaining -= 1; - Some(value) - } - - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) - } -} - -impl ExactSizeIterator for CoordinateIter<'_> {} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -enum CommandState { - #[default] - BetweenContours, - ActiveWithoutSegment, - ActiveWithSegment, -} - -fn validate_order(state: &mut CommandState, command: u8, index: usize) -> Result<(), CodecError> { - match command { - 0 => *state = CommandState::ActiveWithoutSegment, - 1..=3 => match state { - CommandState::BetweenContours => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::DrawingCommandWithoutContour, - }); - } - CommandState::ActiveWithoutSegment | CommandState::ActiveWithSegment => { - *state = CommandState::ActiveWithSegment; - } - }, - 4 => match state { - CommandState::BetweenContours => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::CloseWithoutContour, - }); - } - CommandState::ActiveWithoutSegment => { - return Err(CodecError::InvalidCommandOrder { - index, - reason: CommandOrderError::CloseWithoutDrawingSegment, - }); - } - CommandState::ActiveWithSegment => *state = CommandState::BetweenContours, - }, - _ => unreachable!("command byte validated before ordering"), - } - - Ok(()) -} - -fn command_arity(command: u8) -> Option { - match command { - 0 | 1 => Some(2), - 2 => Some(4), - 3 => Some(6), - 4 => Some(0), - _ => None, - } -} - -fn convert_coordinate(value: f64, index: usize) -> Result { - if !value.is_finite() { - return Err(CodecError::NonFiniteInputCoordinate { index }); - } - - let converted = value as f32; - if !converted.is_finite() { - return Err(CodecError::CoordinateOutOfF32Range { index }); - } - - Ok(converted) -} - -fn check_limit(field: &'static str, actual: usize, limit: usize) -> Result<(), CodecError> { - if actual > limit { - return Err(CodecError::LimitExceeded { - field, - limit, - actual, - }); - } - Ok(()) -} - -fn align4(value: usize) -> Option { - value.checked_add(3).map(|value| value & !3) -} - -fn read_u32(bytes: &[u8], offset: usize) -> u32 { - u32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) -} - -fn read_f32(bytes: &[u8], offset: usize) -> f32 { - f32::from_le_bytes(bytes[offset..offset + 4].try_into().expect("fixed width")) -} diff --git a/crates/shift-glyph-codec/tests/fixtures.rs b/crates/shift-glyph-codec/tests/fixtures.rs deleted file mode 100644 index d5f521c8..00000000 --- a/crates/shift-glyph-codec/tests/fixtures.rs +++ /dev/null @@ -1,146 +0,0 @@ -use std::{collections::HashSet, fs, path::PathBuf}; - -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use shift_glyph_codec::{decode_layer, decode_outline, pack_layer, pack_outline, OutlineCommand}; - -#[derive(Deserialize)] -struct LayerManifest { - format: String, - vectors: Vec, -} - -#[derive(Deserialize)] -struct BinaryVector { - file: String, - bytes: usize, - sha256: String, -} - -#[derive(Deserialize)] -struct OutlineManifest { - format: String, - vectors: Vec, -} - -#[derive(Deserialize)] -struct OutlineVector { - name: String, - file: String, - bytes: usize, - sha256: String, - commands: Vec, -} - -#[derive(Deserialize)] -struct ManifestCommand { - kind: String, - coordinates: Vec, -} - -#[test] -fn layer_manifest_declares_every_canonical_fixture() { - let directory = fixture_directory("layer-v1"); - let manifest: LayerManifest = - serde_json::from_slice(&fs::read(directory.join("vectors.json")).unwrap()).unwrap(); - assert_eq!(manifest.format, "shift.glyph-layer.v1"); - assert_unique_files(manifest.vectors.iter().map(|vector| vector.file.as_str())); - - for vector in manifest.vectors { - let bytes = fs::read(directory.join(&vector.file)).unwrap(); - assert_binary_facts(&vector, &bytes); - let layer = decode_layer(&bytes).unwrap().unpack(); - assert_eq!(pack_layer(&layer).unwrap().as_bytes(), bytes); - } -} - -#[test] -fn outline_manifest_declares_every_canonical_fixture() { - let directory = fixture_directory("outline-v1"); - let manifest: OutlineManifest = - serde_json::from_slice(&fs::read(directory.join("vectors.json")).unwrap()).unwrap(); - assert_eq!(manifest.format, "shift.glyph-outline.v1"); - let vectors = manifest.vectors; - assert_unique_files(vectors.iter().map(|vector| vector.file.as_str())); - assert_eq!( - vectors - .iter() - .map(|vector| vector.name.as_str()) - .collect::>() - .len(), - vectors.len() - ); - - for vector in vectors { - let bytes = fs::read(directory.join(&vector.file)).unwrap(); - assert_binary_facts( - &BinaryVector { - file: vector.file, - bytes: vector.bytes, - sha256: vector.sha256, - }, - &bytes, - ); - let commands = vector - .commands - .iter() - .map(manifest_command) - .collect::>(); - assert_eq!(pack_outline(&commands).unwrap().as_bytes(), bytes); - assert_eq!( - decode_outline(&bytes).unwrap().command_count(), - commands.len() - ); - } -} - -fn fixture_directory(format: &str) -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../fixtures/glyph-codec") - .join(format) -} - -fn assert_unique_files<'a>(files: impl Iterator) { - let files = files.collect::>(); - assert_eq!( - files.iter().copied().collect::>().len(), - files.len() - ); -} - -fn assert_binary_facts(vector: &BinaryVector, bytes: &[u8]) { - assert_eq!(bytes.len(), vector.bytes, "{} byte length", vector.file); - assert_eq!( - hex(&Sha256::digest(bytes)), - vector.sha256, - "{} SHA-256", - vector.file - ); -} - -fn manifest_command(command: &ManifestCommand) -> OutlineCommand { - match (command.kind.as_str(), command.coordinates.as_slice()) { - ("move", [x, y]) => OutlineCommand::Move { x: *x, y: *y }, - ("line", [x, y]) => OutlineCommand::Line { x: *x, y: *y }, - ("quad", [cx, cy, x, y]) => OutlineCommand::Quad { - cx: *cx, - cy: *cy, - x: *x, - y: *y, - }, - ("cubic", [c1x, c1y, c2x, c2y, x, y]) => OutlineCommand::Cubic { - c1x: *c1x, - c1y: *c1y, - c2x: *c2x, - c2y: *c2y, - x: *x, - y: *y, - }, - ("close", []) => OutlineCommand::Close, - _ => panic!("invalid manifest command {}", command.kind), - } -} - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} diff --git a/crates/shift-glyph-codec/tests/layer.rs b/crates/shift-glyph-codec/tests/layer.rs deleted file mode 100644 index 21e7be08..00000000 --- a/crates/shift-glyph-codec/tests/layer.rs +++ /dev/null @@ -1,425 +0,0 @@ -use std::collections::BTreeMap; - -use shift_glyph_codec::{ - decode_layer, pack_layer, GlyphLayer, LayerAnchor, LayerCodecError, LayerComponent, - LayerContour, LayerGuideline, LayerLibValue, LayerPoint, LayerPointType, LayerTransform, -}; - -const EMPTY: &[u8] = include_bytes!("../../../fixtures/glyph-codec/layer-v1/empty.bin"); -const FULL: &[u8] = include_bytes!("../../../fixtures/glyph-codec/layer-v1/full.bin"); - -fn full_layer() -> GlyphLayer { - let mut nested = BTreeMap::new(); - nested.insert( - "alpha".to_string(), - LayerLibValue::String("value".to_string()), - ); - nested.insert("omega".to_string(), LayerLibValue::Uid(u64::MAX)); - - let mut lib = BTreeMap::new(); - lib.insert( - "array".to_string(), - LayerLibValue::Array(vec![ - LayerLibValue::Boolean(false), - LayerLibValue::Boolean(true), - ]), - ); - lib.insert("data".to_string(), LayerLibValue::Data(vec![0, 1, 2, 255])); - lib.insert( - "date".to_string(), - LayerLibValue::Date("2026-07-25T12:34:56Z".to_string()), - ); - lib.insert("dict".to_string(), LayerLibValue::Dict(nested)); - lib.insert("float".to_string(), LayerLibValue::Float(-12.25)); - lib.insert("integer".to_string(), LayerLibValue::Integer(i64::MIN)); - lib.insert( - "string".to_string(), - LayerLibValue::String("layer metadata".to_string()), - ); - lib.insert( - "unsigned".to_string(), - LayerLibValue::UnsignedInteger(u64::MAX), - ); - - GlyphLayer { - id: "layer_A_regular".to_string(), - source_id: "source_regular".to_string(), - width: 600.125, - height: Some(720.5), - contours: vec![ - LayerContour { - id: "contour_outer".to_string(), - closed: true, - points: vec![ - LayerPoint { - id: "point_0".to_string(), - point_type: LayerPointType::OnCurve, - smooth: false, - x: 100.25, - y: -0.0, - }, - LayerPoint { - id: "point_1".to_string(), - point_type: LayerPointType::OffCurve, - smooth: true, - x: 300.5, - y: 700.75, - }, - LayerPoint { - id: "point_2".to_string(), - point_type: LayerPointType::QCurve, - smooth: false, - x: 500.875, - y: 0.0, - }, - ], - }, - LayerContour { - id: "contour_open".to_string(), - closed: false, - points: vec![], - }, - ], - components: vec![LayerComponent { - id: "component_acute".to_string(), - base_glyph_id: "glyph_acute".to_string(), - base_glyph_name: "acutecomb".to_string(), - transform: LayerTransform { - translate_x: 10.0, - translate_y: 20.0, - rotation: 5.5, - scale_x: 1.25, - scale_y: 0.75, - skew_x: 2.0, - skew_y: -1.5, - center_x: 50.0, - center_y: 75.0, - }, - }], - anchors: vec![ - LayerAnchor { - id: "anchor_top".to_string(), - name: Some("top".to_string()), - x: 300.0, - y: 700.0, - }, - LayerAnchor { - id: "anchor_unnamed".to_string(), - name: None, - x: 12.5, - y: -30.25, - }, - ], - guidelines: vec![ - LayerGuideline { - id: "guideline_baseline".to_string(), - x: None, - y: Some(0.0), - angle: None, - name: Some("Baseline".to_string()), - color: Some("0,0,1,1".to_string()), - }, - LayerGuideline { - id: "guideline_angle".to_string(), - x: Some(100.0), - y: Some(200.0), - angle: Some(45.0), - name: None, - color: None, - }, - ], - lib, - } -} - -fn string_section_end(bytes: &[u8]) -> usize { - let count = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; - let string_bytes = u32::from_le_bytes(bytes[16..20].try_into().unwrap()) as usize; - 72 + (count + 1) * 4 + string_bytes -} - -fn string_ranges(bytes: &[u8]) -> Vec> { - let count = u32::from_le_bytes(bytes[12..16].try_into().unwrap()) as usize; - let values_offset = 72 + (count + 1) * 4; - let mut previous = 0; - (0..count) - .map(|index| { - let offset = 72 + (index + 1) * 4; - let end = u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize; - let range = values_offset + previous..values_offset + end; - previous = end; - range - }) - .collect() -} - -#[test] -fn shared_golden_vectors_decode_and_reencode_byte_for_byte() { - let expected = full_layer(); - assert_eq!(decode_layer(FULL).unwrap().unpack(), expected); - assert_eq!(pack_layer(&expected).unwrap().as_bytes(), FULL); - - let empty = GlyphLayer::empty("layer_empty", "source_regular"); - assert_eq!(decode_layer(EMPTY).unwrap().unpack(), empty); - assert_eq!(pack_layer(&empty).unwrap().as_bytes(), EMPTY); -} - -#[test] -fn authored_layer_roundtrips_losslessly() { - let expected = full_layer(); - let packed = pack_layer(&expected).unwrap(); - let view = packed.view(); - - assert_eq!(view.id(), expected.id); - assert_eq!(view.source_id(), expected.source_id); - assert_eq!(view.width().to_bits(), expected.width.to_bits()); - assert_eq!( - view.height().unwrap().to_bits(), - expected.height.unwrap().to_bits() - ); - assert_eq!(view.contour_count(), 2); - assert_eq!(view.point_count(), 3); - assert_eq!(view.component_count(), 1); - assert_eq!(view.anchor_count(), 2); - assert_eq!(view.guideline_count(), 2); - assert_eq!(view.unpack(), expected); -} - -#[test] -fn view_streams_structure_without_materializing_the_layer() { - let packed = pack_layer(&full_layer()).unwrap(); - let view = packed.view(); - let contours = view.contours().collect::>(); - - assert_eq!(contours[0].id(), "contour_outer"); - assert!(contours[0].closed()); - assert_eq!(contours[0].point_count(), 3); - assert_eq!( - contours[0] - .points() - .map(|point| ( - point.id(), - point.point_type(), - point.x().to_bits(), - point.y().to_bits() - )) - .collect::>(), - vec![ - ( - "point_0", - LayerPointType::OnCurve, - 100.25_f64.to_bits(), - (-0.0_f64).to_bits() - ), - ( - "point_1", - LayerPointType::OffCurve, - 300.5_f64.to_bits(), - 700.75_f64.to_bits() - ), - ( - "point_2", - LayerPointType::QCurve, - 500.875_f64.to_bits(), - 0.0_f64.to_bits() - ), - ] - ); - assert_eq!( - view.components().next().unwrap().base_glyph_id(), - "glyph_acute" - ); - assert_eq!(view.anchors().nth(1).unwrap().name(), None); - assert_eq!(view.guidelines().next().unwrap().y(), Some(0.0)); -} - -#[test] -fn encoding_is_deterministic_across_dictionary_insertion_order() { - let first = full_layer(); - let mut second = full_layer(); - second.lib = second.lib.into_iter().rev().collect(); - - assert_eq!(pack_layer(&first).unwrap(), pack_layer(&second).unwrap()); -} - -#[test] -fn decoder_rejects_all_truncations_and_trailing_bytes() { - let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); - for length in 0..bytes.len() { - assert!( - decode_layer(&bytes[..length]).is_err(), - "accepted truncation at {length}" - ); - } - - let mut trailing = bytes; - trailing.push(0); - assert!(matches!( - decode_layer(&trailing), - Err(LayerCodecError::LengthMismatch { .. }) - )); -} - -#[test] -fn decoder_rejects_framing_flags_types_and_noncanonical_absence() { - let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); - - let mut wrong_magic = bytes.clone(); - wrong_magic[0] = 0; - assert_eq!( - decode_layer(&wrong_magic).unwrap_err(), - LayerCodecError::WrongMagic - ); - - let mut wrong_kind = bytes.clone(); - wrong_kind[4] = 1; - assert_eq!( - decode_layer(&wrong_kind).unwrap_err(), - LayerCodecError::UnsupportedPayloadKind(1) - ); - - let mut wrong_version = bytes.clone(); - wrong_version[5] = 2; - assert_eq!( - decode_layer(&wrong_version).unwrap_err(), - LayerCodecError::UnsupportedVersion(2) - ); - - let mut frame_flags = bytes.clone(); - frame_flags[6] = 1; - assert_eq!( - decode_layer(&frame_flags).unwrap_err(), - LayerCodecError::UnknownFlags(1) - ); - - let mut layer_flags = bytes.clone(); - layer_flags[52] = 2; - assert_eq!( - decode_layer(&layer_flags).unwrap_err(), - LayerCodecError::UnknownLayerFlags(2) - ); - - let mut point_type = bytes; - let point_type_offset = string_section_end(&point_type) + 12 + 4; - point_type[point_type_offset] = 3; - assert!(matches!( - decode_layer(&point_type), - Err(LayerCodecError::UnknownPointType { .. }) - )); - - let mut empty = pack_layer(&GlyphLayer::empty("layer_empty", "source_regular")) - .unwrap() - .into_bytes(); - empty[71] = 0x80; - assert!(matches!( - decode_layer(&empty), - Err(LayerCodecError::NonCanonicalAbsentNumber { - field: "height", - .. - }) - )); -} - -#[test] -fn encoder_rejects_nonfinite_numbers_and_duplicate_identity() { - let mut nonfinite = full_layer(); - nonfinite.contours[0].points[0].x = f64::NAN; - assert!(matches!( - pack_layer(&nonfinite), - Err(LayerCodecError::NonFiniteNumber { .. }) - )); - - let mut duplicate = full_layer(); - duplicate.contours[0].points[1].id = duplicate.contours[0].points[0].id.clone(); - assert!(matches!( - pack_layer(&duplicate), - Err(LayerCodecError::DuplicateIdentity { kind: "point", .. }) - )); -} - -#[test] -fn decoder_rejects_deterministic_hostile_string_reference_tag_and_count_cases() { - let bytes = pack_layer(&full_layer()).unwrap().into_bytes(); - - let mut invalid_utf8 = bytes.clone(); - let first_string_start = string_ranges(&invalid_utf8)[0].start; - invalid_utf8[first_string_start] = 0xff; - assert_eq!( - decode_layer(&invalid_utf8).unwrap_err(), - LayerCodecError::InvalidUtf8 { index: 0 } - ); - - let ranges = string_ranges(&bytes); - let (first, duplicate) = ranges - .iter() - .enumerate() - .flat_map(|(first_index, first)| { - ranges - .iter() - .enumerate() - .skip(first_index + 1) - .map(move |(_, duplicate)| (first, duplicate)) - }) - .find(|(first, duplicate)| first.len() == duplicate.len()) - .expect("fixture should contain two equally sized strings"); - let mut duplicate_string = bytes.clone(); - let first_bytes = duplicate_string[first.clone()].to_vec(); - duplicate_string[duplicate.clone()].copy_from_slice(&first_bytes); - assert!(matches!( - decode_layer(&duplicate_string), - Err(LayerCodecError::DuplicateString { .. }) - )); - - let mut noncanonical_reference = bytes.clone(); - noncanonical_reference[44..48].copy_from_slice(&2_u32.to_le_bytes()); - assert!(matches!( - decode_layer(&noncanonical_reference), - Err(LayerCodecError::NonCanonicalStringReference { - expected_at_most: 0, - actual: 2 - }) - )); - - let lib_bytes = u32::from_le_bytes(bytes[40..44].try_into().unwrap()) as usize; - let mut unknown_tag = bytes.clone(); - let lib_offset = unknown_tag.len() - lib_bytes; - unknown_tag[lib_offset + 8] = 0xff; - assert!(matches!( - decode_layer(&unknown_tag), - Err(LayerCodecError::UnknownLibTag { value: 0xff, .. }) - )); - - let mut excessive_points = bytes; - excessive_points[24..28].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(matches!( - decode_layer(&excessive_points), - Err(LayerCodecError::LimitExceeded { - field: "point count", - .. - }) - )); -} - -#[test] -fn mutated_payloads_never_panic_and_accepted_bytes_reencode_canonically() { - let seed = pack_layer(&full_layer()).unwrap().into_bytes(); - let mut random = 0x81d2_67a5_u32; - - for iteration in 0..10_000 { - random ^= random << 13; - random ^= random >> 17; - random ^= random << 5; - let mut candidate = seed.clone(); - let offset = random as usize % candidate.len(); - candidate[offset] ^= (random >> 24) as u8 | 1; - if iteration % 7 == 0 { - candidate.truncate(random as usize % candidate.len()); - } else if iteration % 11 == 0 { - candidate.push((random >> 16) as u8); - } - - if let Ok(view) = decode_layer(&candidate) { - assert_eq!(pack_layer(&view.unpack()).unwrap().as_bytes(), candidate); - } - } -} diff --git a/crates/shift-glyph-codec/tests/outline.rs b/crates/shift-glyph-codec/tests/outline.rs deleted file mode 100644 index ad3f196e..00000000 --- a/crates/shift-glyph-codec/tests/outline.rs +++ /dev/null @@ -1,386 +0,0 @@ -use shift_glyph_codec::{ - decode_outline, pack_outline, CodecError, CommandOrderError, OutlineCommand, MAX_COMMAND_COUNT, -}; - -type Command64 = OutlineCommand; -type Command32 = OutlineCommand; - -const EMPTY: &[u8] = include_bytes!("../../../fixtures/glyph-codec/outline-v1/empty.bin"); -const CLOSED_LINE: &[u8] = - include_bytes!("../../../fixtures/glyph-codec/outline-v1/closed-line.bin"); -const OPEN: &[u8] = include_bytes!("../../../fixtures/glyph-codec/outline-v1/open.bin"); -const CURVES: &[u8] = - include_bytes!("../../../fixtures/glyph-codec/outline-v1/quadratic-cubic.bin"); -const MULTIPLE: &[u8] = - include_bytes!("../../../fixtures/glyph-codec/outline-v1/multiple-contours.bin"); - -fn golden_vectors() -> Vec<(&'static str, &'static [u8], Vec)> { - vec![ - ("empty", EMPTY, vec![]), - ( - "closed-line", - CLOSED_LINE, - vec![ - Command64::Move { x: 0.0, y: 0.0 }, - Command64::Line { x: 100.0, y: 200.0 }, - Command64::Close, - ], - ), - ( - "open", - OPEN, - vec![ - Command64::Move { x: -12.5, y: 0.25 }, - Command64::Line { - x: 123.75, - y: -456.5, - }, - ], - ), - ( - "quadratic-cubic", - CURVES, - vec![ - Command64::Move { x: -1.5, y: 2.25 }, - Command64::Quad { - cx: 0.5, - cy: -0.75, - x: 1000.125, - y: -2000.5, - }, - Command64::Cubic { - c1x: -3.0, - c1y: 4.5, - c2x: 5.25, - c2y: -6.75, - x: 1.0e20, - y: -1.0e20, - }, - Command64::Close, - ], - ), - ( - "multiple-contours", - MULTIPLE, - vec![ - Command64::Move { x: 0.0, y: 0.0 }, - Command64::Line { x: 10.0, y: 0.0 }, - Command64::Close, - Command64::Move { - x: 100.5, - y: -100.25, - }, - Command64::Line { x: 200.0, y: 300.0 }, - ], - ), - ] -} - -fn as_f32(command: Command64) -> Command32 { - match command { - Command64::Move { x, y } => Command32::Move { - x: x as f32, - y: y as f32, - }, - Command64::Line { x, y } => Command32::Line { - x: x as f32, - y: y as f32, - }, - Command64::Quad { cx, cy, x, y } => Command32::Quad { - cx: cx as f32, - cy: cy as f32, - x: x as f32, - y: y as f32, - }, - Command64::Cubic { - c1x, - c1y, - c2x, - c2y, - x, - y, - } => Command32::Cubic { - c1x: c1x as f32, - c1y: c1y as f32, - c2x: c2x as f32, - c2y: c2y as f32, - x: x as f32, - y: y as f32, - }, - Command64::Close => Command32::Close, - } -} - -fn as_f64(command: Command32) -> Command64 { - match command { - Command32::Move { x, y } => Command64::Move { - x: f64::from(x), - y: f64::from(y), - }, - Command32::Line { x, y } => Command64::Line { - x: f64::from(x), - y: f64::from(y), - }, - Command32::Quad { cx, cy, x, y } => Command64::Quad { - cx: f64::from(cx), - cy: f64::from(cy), - x: f64::from(x), - y: f64::from(y), - }, - Command32::Cubic { - c1x, - c1y, - c2x, - c2y, - x, - y, - } => Command64::Cubic { - c1x: f64::from(c1x), - c1y: f64::from(c1y), - c2x: f64::from(c2x), - c2y: f64::from(c2y), - x: f64::from(x), - y: f64::from(y), - }, - Command32::Close => Command64::Close, - } -} - -fn raw_outline(commands: &[u8], coordinates: &[f32]) -> Vec { - let aligned_commands = (commands.len() + 3) & !3; - let mut bytes = Vec::with_capacity(16 + aligned_commands + coordinates.len() * 4); - bytes.extend_from_slice(b"SHFT"); - bytes.extend_from_slice(&[1, 1, 0, 0]); - bytes.extend_from_slice(&(commands.len() as u32).to_le_bytes()); - bytes.extend_from_slice(&(coordinates.len() as u32).to_le_bytes()); - bytes.extend_from_slice(commands); - bytes.resize(16 + aligned_commands, 0); - for coordinate in coordinates { - bytes.extend_from_slice(&coordinate.to_le_bytes()); - } - bytes -} - -fn canonicalize(bytes: &[u8]) -> Option> { - let view = decode_outline(bytes).ok()?; - let commands = view.commands().map(as_f64).collect::>(); - Some(pack_outline(&commands).unwrap().into_bytes()) -} - -#[test] -fn shared_golden_vectors_decode_and_reencode_byte_for_byte() { - for (name, bytes, expected) in golden_vectors() { - let view = decode_outline(bytes).unwrap_or_else(|error| panic!("{name}: {error}")); - let expected_f32 = expected.iter().copied().map(as_f32).collect::>(); - - assert_eq!(view.commands().collect::>(), expected_f32, "{name}"); - assert_eq!(pack_outline(&expected).unwrap().as_bytes(), bytes, "{name}"); - assert_eq!(canonicalize(bytes).as_deref(), Some(bytes), "{name}"); - } -} - -#[test] -fn golden_vectors_cover_every_command_alignment_padding_length() { - let remainders = golden_vectors() - .into_iter() - .map(|(_, bytes, _)| decode_outline(bytes).unwrap().command_count() % 4) - .collect::>(); - - assert_eq!(remainders, [0, 1, 2, 3].into_iter().collect()); -} - -#[test] -fn decoder_rejects_truncation_and_trailing_bytes() { - for length in 0..CURVES.len() { - assert!( - decode_outline(&CURVES[..length]).is_err(), - "length {length}" - ); - } - - let mut trailing = CURVES.to_vec(); - trailing.push(0); - assert!(matches!( - decode_outline(&trailing), - Err(CodecError::LengthMismatch { .. }) - )); -} - -#[test] -fn decoder_rejects_wrong_framing_and_unknown_flags() { - let mut bytes = OPEN.to_vec(); - bytes[0] = b'X'; - assert_eq!(decode_outline(&bytes), Err(CodecError::WrongMagic)); - - let mut bytes = OPEN.to_vec(); - bytes[4] = 2; - assert_eq!( - decode_outline(&bytes), - Err(CodecError::UnsupportedPayloadKind(2)) - ); - - let mut bytes = OPEN.to_vec(); - bytes[5] = 2; - assert_eq!( - decode_outline(&bytes), - Err(CodecError::UnsupportedVersion(2)) - ); - - let mut bytes = OPEN.to_vec(); - bytes[6] = 1; - assert_eq!(decode_outline(&bytes), Err(CodecError::UnknownFlags(1))); -} - -#[test] -fn decoder_rejects_unknown_commands_and_every_illegal_ordering_boundary() { - let unknown = raw_outline(&[9], &[]); - assert!(matches!( - decode_outline(&unknown), - Err(CodecError::UnknownCommand { .. }) - )); - - let drawing_first = raw_outline(&[1], &[1.0, 2.0]); - assert_eq!( - decode_outline(&drawing_first), - Err(CodecError::InvalidCommandOrder { - index: 0, - reason: CommandOrderError::DrawingCommandWithoutContour, - }) - ); - - let close_first = raw_outline(&[4], &[]); - assert_eq!( - decode_outline(&close_first), - Err(CodecError::InvalidCommandOrder { - index: 0, - reason: CommandOrderError::CloseWithoutContour, - }) - ); - - let empty_close = raw_outline(&[0, 4], &[0.0, 0.0]); - assert_eq!( - decode_outline(&empty_close), - Err(CodecError::InvalidCommandOrder { - index: 1, - reason: CommandOrderError::CloseWithoutDrawingSegment, - }) - ); - - let after_close = raw_outline(&[0, 1, 4, 1], &[0.0, 0.0, 1.0, 1.0, 2.0, 2.0]); - assert!(matches!( - decode_outline(&after_close), - Err(CodecError::InvalidCommandOrder { index: 3, .. }) - )); -} - -#[test] -fn decoder_rejects_arity_padding_and_non_finite_coordinates() { - let wrong_arity = raw_outline(&[0, 1], &[0.0, 0.0]); - assert_eq!( - decode_outline(&wrong_arity), - Err(CodecError::CoordinateCountMismatch { - expected: 4, - actual: 2, - }) - ); - - let mut non_zero_padding = OPEN.to_vec(); - non_zero_padding[18] = 1; - assert_eq!( - decode_outline(&non_zero_padding), - Err(CodecError::NonZeroPadding { offset: 18 }) - ); - - for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { - let non_finite = raw_outline(&[0], &[value, 0.0]); - assert_eq!( - decode_outline(&non_finite), - Err(CodecError::NonFiniteCoordinate { index: 0 }) - ); - } -} - -#[test] -fn encoder_rejects_illegal_command_ordering() { - assert!(matches!( - pack_outline(&[Command64::Line { x: 1.0, y: 2.0 }]), - Err(CodecError::InvalidCommandOrder { - reason: CommandOrderError::DrawingCommandWithoutContour, - .. - }) - )); - assert!(matches!( - pack_outline(&[Command64::Close]), - Err(CodecError::InvalidCommandOrder { - reason: CommandOrderError::CloseWithoutContour, - .. - }) - )); - assert!(matches!( - pack_outline(&[Command64::Move { x: 0.0, y: 0.0 }, Command64::Close]), - Err(CodecError::InvalidCommandOrder { - reason: CommandOrderError::CloseWithoutDrawingSegment, - .. - }) - )); -} - -#[test] -fn encoder_rejects_non_finite_and_out_of_range_input() { - for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { - assert!(matches!( - pack_outline(&[Command64::Move { x: value, y: 0.0 }]), - Err(CodecError::NonFiniteInputCoordinate { index: 0 }) - )); - } - - assert!(matches!( - pack_outline(&[Command64::Move { - x: f64::MAX, - y: 0.0 - }]), - Err(CodecError::CoordinateOutOfF32Range { index: 0 }) - )); -} - -#[test] -fn decoder_applies_limits_before_body_iteration() { - let mut bytes = EMPTY.to_vec(); - bytes[8..12].copy_from_slice(&((MAX_COMMAND_COUNT as u32) + 1).to_le_bytes()); - - assert!(matches!( - decode_outline(&bytes), - Err(CodecError::LimitExceeded { - field: "command count", - .. - }) - )); -} - -#[test] -fn mutated_payloads_never_panic_and_any_accepted_payload_is_canonical() { - let seed_commands = golden_vectors() - .into_iter() - .flat_map(|(_, _, commands)| commands) - .collect::>(); - let seed = pack_outline(&seed_commands).unwrap().into_bytes(); - let mut random = 0x7a11_4e29_u32; - - for iteration in 0..10_000 { - random ^= random << 13; - random ^= random >> 17; - random ^= random << 5; - let mut candidate = seed.clone(); - let offset = random as usize % candidate.len(); - candidate[offset] ^= (random >> 24) as u8 | 1; - if iteration % 7 == 0 { - candidate.truncate(random as usize % candidate.len()); - } else if iteration % 11 == 0 { - candidate.push((random >> 16) as u8); - } - - if let Some(canonical) = canonicalize(&candidate) { - assert_eq!(canonical, candidate); - } - } -} diff --git a/crates/shift-slug/Cargo.toml b/crates/shift-slug/Cargo.toml index 4ff7a6e3..cb5275ba 100644 --- a/crates/shift-slug/Cargo.toml +++ b/crates/shift-slug/Cargo.toml @@ -14,7 +14,6 @@ wgpu-benchmark = ["dep:naga", "dep:pollster", "dep:wgpu"] [dependencies] shift-font = { workspace = true } -shift-glyph-codec = { workspace = true } naga = { version = "30.0.0", optional = true, features = ["wgsl-in"] } pollster = { version = "0.4", optional = true } diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index c9feb3c1..5209534e 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -11,12 +11,14 @@ GPU-independent preprocessing for the experimental Slug home/catalog glyph grid. - **Bands are location-bound.** The static builder bands one resolved shape. The variable path resolves and re-bands only visible glyphs after every weight update, so current-location membership stays exact without geometry upload. - **Deterministic topology conversion.** Lines become quadratics. Cubics use the conservative third-derivative error bound from Kurbo's `CubicBez::to_quads`, with a one-font-unit tolerance and equal parameter intervals. Compatible authored sources freeze the maximum subdivision count required by any source so variable topology remains identical. - **No shaping.** The grid addresses glyphs by dense atlas index and does not need a text shaper. +- **Command ownership.** `OutlineCommand` is a Slug preprocessing input. No standalone packed-outline storage format exists. ## Codemap ```text src/ lib.rs public boundary + outline.rs Slug-owned drawing command type curve.rs outline commands -> quadratic curves atlas.rs glyph bounds, horizontal/vertical bands, atlas assembly authored.rs stable shift-font point/segment topology and source derivation diff --git a/crates/shift-slug/examples/analyze_font.rs b/crates/shift-slug/examples/analyze_font.rs index 2efc7c9c..5959be0d 100644 --- a/crates/shift-slug/examples/analyze_font.rs +++ b/crates/shift-slug/examples/analyze_font.rs @@ -1,7 +1,6 @@ use std::{env, error::Error, fs, path::PathBuf, time::Instant}; -use shift_glyph_codec::OutlineCommand; -use shift_slug::{AtlasBuilder, CurveIndexEncoding, DEFAULT_BAND_COUNT}; +use shift_slug::{AtlasBuilder, CurveIndexEncoding, OutlineCommand, DEFAULT_BAND_COUNT}; use skrifa::{ outline::{DrawSettings, OutlinePen}, prelude::{LocationRef, Size}, diff --git a/crates/shift-slug/examples/benchmark_variable_wgpu.rs b/crates/shift-slug/examples/benchmark_variable_wgpu.rs index 2e77fdd5..77c5e51d 100644 --- a/crates/shift-slug/examples/benchmark_variable_wgpu.rs +++ b/crates/shift-slug/examples/benchmark_variable_wgpu.rs @@ -1,9 +1,8 @@ use std::{env, error::Error, fs, path::PathBuf, sync::mpsc, time::Instant}; -use shift_glyph_codec::OutlineCommand; use shift_slug::{ - pack_render_instances, pack_render_params, pack_variable_params, Bounds, Curve, RenderInstance, - RenderParams, SlugError, VariableAtlas, VariableAtlasBuilder, VariableParams, + pack_render_instances, pack_render_params, pack_variable_params, Bounds, Curve, OutlineCommand, + RenderInstance, RenderParams, SlugError, VariableAtlas, VariableAtlasBuilder, VariableParams, DEFAULT_BAND_COUNT, VARIABLE_SLUG_WGSL, }; use skrifa::{ diff --git a/crates/shift-slug/examples/benchmark_wgpu.rs b/crates/shift-slug/examples/benchmark_wgpu.rs index b4c43328..a59b5db2 100644 --- a/crates/shift-slug/examples/benchmark_wgpu.rs +++ b/crates/shift-slug/examples/benchmark_wgpu.rs @@ -8,10 +8,9 @@ use std::{ time::{Duration, Instant}, }; -use shift_glyph_codec::OutlineCommand; use shift_slug::{ pack_render_instances, pack_render_params, Atlas, AtlasBuilder, Bounds, CurveIndexEncoding, - Layout, RenderInstance, RenderParams, Section, DEFAULT_BAND_COUNT, SLUG_WGSL, + Layout, OutlineCommand, RenderInstance, RenderParams, Section, DEFAULT_BAND_COUNT, SLUG_WGSL, }; use skrifa::{ outline::{DrawSettings, OutlinePen}, diff --git a/crates/shift-slug/src/atlas.rs b/crates/shift-slug/src/atlas.rs index 0aabbdcd..7ec968d1 100644 --- a/crates/shift-slug/src/atlas.rs +++ b/crates/shift-slug/src/atlas.rs @@ -1,9 +1,8 @@ use std::cmp::Ordering; -use shift_glyph_codec::OutlineCommand; - use crate::{ - curve::curves_from_commands, Bounds, Curve, CurveIndexEncoding, Layout, PackedAtlas, SlugError, + curve::curves_from_commands, Bounds, Curve, CurveIndexEncoding, Layout, OutlineCommand, + PackedAtlas, SlugError, }; pub const DEFAULT_BAND_COUNT: u32 = 8; diff --git a/crates/shift-slug/src/curve.rs b/crates/shift-slug/src/curve.rs index 64ca4f1a..5aea591c 100644 --- a/crates/shift-slug/src/curve.rs +++ b/crates/shift-slug/src/curve.rs @@ -1,6 +1,4 @@ -use shift_glyph_codec::OutlineCommand; - -use crate::SlugError; +use crate::{OutlineCommand, SlugError}; /// Maximum font-space distance between a source cubic and its quadratic approximation. pub const CUBIC_APPROXIMATION_ACCURACY: f32 = 1.0; diff --git a/crates/shift-slug/src/lib.rs b/crates/shift-slug/src/lib.rs index 3a808647..1b4ad59c 100644 --- a/crates/shift-slug/src/lib.rs +++ b/crates/shift-slug/src/lib.rs @@ -9,6 +9,7 @@ mod atlas; mod authored; mod curve; mod error; +mod outline; mod pack; mod render; mod resident; @@ -24,6 +25,7 @@ pub use authored::{ }; pub use curve::{Bounds, Curve, Point, CUBIC_APPROXIMATION_ACCURACY, LINE_EPSILON}; pub use error::SlugError; +pub use outline::OutlineCommand; pub use pack::{CurveIndexEncoding, Layout, PackedAtlas, Section}; pub use render::{ pack_render_instances, pack_render_params, RenderInstance, RenderParams, RENDER_INSTANCE_BYTES, diff --git a/crates/shift-slug/src/outline.rs b/crates/shift-slug/src/outline.rs new file mode 100644 index 00000000..e96b9db8 --- /dev/null +++ b/crates/shift-slug/src/outline.rs @@ -0,0 +1,27 @@ +/// One drawing command consumed by Slug preprocessing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OutlineCommand { + Move { + x: T, + y: T, + }, + Line { + x: T, + y: T, + }, + Quad { + cx: T, + cy: T, + x: T, + y: T, + }, + Cubic { + c1x: T, + c1y: T, + c2x: T, + c2y: T, + x: T, + y: T, + }, + Close, +} diff --git a/crates/shift-slug/src/variable.rs b/crates/shift-slug/src/variable.rs index 3f87e211..dda18abc 100644 --- a/crates/shift-slug/src/variable.rs +++ b/crates/shift-slug/src/variable.rs @@ -1,11 +1,9 @@ -use shift_glyph_codec::OutlineCommand; - use crate::{ curve::{ cubic_subdivision_counts_from_commands, curves_and_line_flags_from_commands_with_subdivisions, }, - Bounds, Curve, Point, Section, SlugError, LINE_EPSILON, + Bounds, Curve, OutlineCommand, Point, Section, SlugError, LINE_EPSILON, }; const CURVE_BYTES: usize = 24; diff --git a/crates/shift-slug/tests/atlas.rs b/crates/shift-slug/tests/atlas.rs index 342f2736..4cda6130 100644 --- a/crates/shift-slug/tests/atlas.rs +++ b/crates/shift-slug/tests/atlas.rs @@ -1,4 +1,4 @@ -use shift_glyph_codec::OutlineCommand; +use shift_slug::OutlineCommand; #[cfg(feature = "wgpu-benchmark")] use shift_slug::SLUG_WGSL; use shift_slug::{ diff --git a/crates/shift-slug/tests/variable_model.rs b/crates/shift-slug/tests/variable_model.rs index 588f0eb3..2d7155a3 100644 --- a/crates/shift-slug/tests/variable_model.rs +++ b/crates/shift-slug/tests/variable_model.rs @@ -1,8 +1,7 @@ use shift_font::{ test_support::sample_variable_font, CurveSegment, CurveSegmentIter, GlyphId, Location, }; -use shift_glyph_codec::OutlineCommand; -use shift_slug::{AtlasBuilder, VariableAtlasBuilder}; +use shift_slug::{AtlasBuilder, OutlineCommand, VariableAtlasBuilder}; #[test] fn resident_two_source_model_matches_shift_projection_at_midpoint() { diff --git a/crates/shift-source/src/package.rs b/crates/shift-source/src/package.rs index 97734958..b5978ca6 100644 --- a/crates/shift-source/src/package.rs +++ b/crates/shift-source/src/package.rs @@ -1605,7 +1605,7 @@ impl TryFrom for LibValue { values .into_iter() .map(|(key, value)| value.try_into().map(|value| (key, value))) - .collect::, _>>()?, + .collect::, _>>()?, ), LibValueDoc::Data(value) => Self::Data(value), LibValueDoc::Date(value) => Self::Date(value), diff --git a/crates/shift-store/Cargo.toml b/crates/shift-store/Cargo.toml index 9877486b..f964fa81 100644 --- a/crates/shift-store/Cargo.toml +++ b/crates/shift-store/Cargo.toml @@ -8,6 +8,8 @@ shift-font = { workspace = true } blake3 = "1.8" rusqlite = { version = "0.37.0", features = ["blob", "backup", "limits"] } rayon = "1.10" +rmp-serde = "1.3" +serde = "1.0" serde_json = "1.0" thiserror = "2" zstd = "0.13" diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index 40aa8d4f..c6cdbbef 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -7,31 +7,30 @@ Callers use typed APIs from this crate rather than preparing SQL or opening a se ## Storage boundary - Font metadata and the glyph/layer directory remain relational and load eagerly. Layer rows reference their glyph rather than duplicating its editable name. -- Each editable glyph layer is one independently addressable payload. Its canonical decoded bytes remain `shift.glyph-layer.v1`; the store independently wraps them as `none` or `zstd.v1` without changing authored semantics. +- Each editable glyph layer is one independently addressable, store-private MessagePack payload identified as `shift.glyph-layer.v1`; the store independently wraps it as `none` or `zstd.v1` without changing authored semantics. - Component dependency rows contain only the earned query index: component ID, owner layer ID, base glyph ID, and ordinal. - Points, contours, anchors, transforms, guidelines, and layer lib values are canonical only inside the layer BLOB; they are not duplicated as normalized SQL rows. - Payload replacement, directory facts, component rows, and workspace revision state commit in one transaction. -`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` accepts a complete requested set, deduplicates it, and partitions it through the single count- and decoded-byte-aware planner. Each internal batch performs three ordered directory/payload/reference scans for at most 512 layers and 256 MiB decoded bytes, then decompresses and validates with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict canonical decoding. Full-font materialization and workspace acquisition use that same planner; no caller maintains a weaker count-only loop. All payload paths cross-check canonical bytes against relational facts. +`ShiftStore::load_font_directory` never reads payload BLOBs. `load_glyph_layer` bounds stored and decoded lengths before fetching and decompressing one payload. `load_glyph_layers` accepts a complete requested set, deduplicates it, and partitions it through the single count- and decoded-byte-aware planner. Each internal batch performs three ordered directory/payload/reference scans for at most 512 layers and 256 MiB decoded bytes, then decompresses and validates with Rayon. Every decoded payload must match its exact declared length and 32-byte BLAKE3 before strict MessagePack decoding. Full-font materialization and workspace acquisition use that same planner; no caller maintains a weaker count-only loop. All payload paths cross-check canonical bytes against relational facts. ## Outer payload contract `glyph_layer_payloads` stores `inner_format`, `compression`, `stored_byte_length`, `decoded_byte_length`, `decoded_blake3`, and `payload`. `inner_format` remains -`shift.glyph-layer.v1`. Compression is either `none` or `zstd.v1`; `zstd.v1` means one complete -independent Zstandard frame with no dictionary. Level 1 is the current write policy, not a decoding +`shift.glyph-layer.v1`. Its bytes are `rmp-serde`'s compact serialization of `GlyphLayer`; struct field order and enum Serde attributes are format-bearing and require a new identifier if changed. Compression is either `none` or `zstd.v1`; `zstd.v1` means one complete independent Zstandard frame with no dictionary. Level 1 is the current write policy, not a decoding requirement. Writers retain compressed bytes only when they are smaller than the canonical input. `decoded_blake3` is the 32-byte BLAKE3 of the exact canonical bytes. Reads bound both lengths before fetch/allocation, reject trailing Zstandard data, require the exact decoded length and hash, and -only then invoke strict canonical decoding. A layer replacement may move between `none` and +only then invoke strict MessagePack decoding. A layer replacement may move between `none` and `zstd.v1` without changing its inner bytes or touching another layer. ## Import boundary -`LayerStreamWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `pack_glyph_batch` canonical-encodes, BLAKE3-hashes, and independently compresses an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, packing/compression, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. +`FontImportWriter` accepts bounded replacement glyph batches without requiring a complete in-memory `Font`. `encode_glyph_batch` MessagePack-encodes, BLAKE3-hashes, and independently compresses an owned batch with Rayon without borrowing the SQLite transaction, allowing the workspace to overlap parsing, encoding/compression, and one stable-order SQLite writer. Streaming inserts, full-state replacement, and change-set replacement share one write implementation parameterized only by insert/upsert mode. Change sets supplied with a committed post-edit font skip incremental decode/re-encode for touched existing layers and write each final layer once. Secondary query indexes are dropped inside the stream transaction and rebuilt in bulk by `finish`; dropping an unfinished writer restores them with the rest of the rollback. Prefix-redundant Unicode-glyph and component-layer indexes are omitted. `finish` is the only stream commit point. -`ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the path is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL. Normal edits never use import pragmas. `FontWorkspace` opens imports at a sibling staging path, writes workspace state there, makes the staged database durable, closes it, atomically installs it at the requested destination, and syncs the parent directory. A failure before installation leaves the previous destination untouched. +`ShiftStore::open_for_import` uses rollback-capable in-memory journaling and disabled synchronous writes only while the foreign source remains authoritative and the path is disposable. `finish_import` syncs the completed database and restores WAL + NORMAL. Normal edits never use import pragmas. `FontWorkspace` opens imports at a sibling staging path, writes workspace state there, makes the staged database durable, closes it, removes the closed staging WAL/SHM sidecars, atomically installs the main database at the requested destination, and syncs the parent directory. A failure before installation leaves the previous destination untouched. ## Schema policy @@ -54,8 +53,8 @@ src/ schema.rs # pre-release version-1 baseline font_state.rs # eager metadata/directory and explicit full materialization packed_layer.rs # bounded BLOB fetch/replace and reference-index checks - stored_layer.rs # independent Zstd/BLAKE3 outer storage representation - stream_writer.rs # independently pipelined Rayon pack/compress plus single-transaction SQLite sink + stored_layer.rs # strict MessagePack plus independent Zstd/BLAKE3 storage representation + stream_writer.rs # independently pipelined Rayon encode/compress plus single-transaction SQLite sink write_mode.rs # shared insert/upsert policy for canonical write paths change_set.rs # transactional workspace changes and one final touched-layer write workspace_state.rs diff --git a/crates/shift-store/src/error.rs b/crates/shift-store/src/error.rs index 4390f82e..97714a62 100644 --- a/crates/shift-store/src/error.rs +++ b/crates/shift-store/src/error.rs @@ -12,8 +12,14 @@ pub enum StoreError { #[error(transparent)] Font(#[from] shift_font::error::CoreError), - #[error(transparent)] - PackedLayer(#[from] shift_font::PackedLayerError), + #[error("glyph layer encoding failed: {0}")] + LayerEncoding(String), + + #[error("glyph layer decoding failed: {0}")] + LayerDecoding(String), + + #[error("invalid glyph layer payload: {0}")] + InvalidLayerPayload(String), #[error("unsupported glyph layer payload format: {0}")] UnsupportedLayerFormat(String), diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index abdc99d2..8f412f1a 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use rusqlite::params; use shift_font as font; @@ -639,7 +639,7 @@ fn lib_value_from_value(value: serde_json::Value) -> Result values .into_iter() .map(|(key, value)| lib_value_from_value(value).map(|value| (key, value))) - .collect::, _>>() + .collect::, _>>() .map(font::LibValue::Dict), _ => Err(StoreError::InvalidLibValue("expected dict".to_string())), }, diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 40aa1da4..6c47466c 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -23,8 +23,8 @@ pub use packed_layer::{ }; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; -pub use stream_writer::{LayerStreamWriter, pack_glyph_batch}; -pub use types::{AxisId, GlyphId, LayerBatchTiming, PackedGlyphBatch, RevisionId, SourceId}; +pub use stream_writer::{FontImportWriter, encode_glyph_batch}; +pub use types::{AxisId, GlyphId, GlyphWriteBatch, LayerBatchTiming, RevisionId, SourceId}; pub use workspace_state::{ Evidence, FileIdentity, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState, }; diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs index b2ff9206..c27fd6ad 100644 --- a/crates/shift-store/src/packed_layer.rs +++ b/crates/shift-store/src/packed_layer.rs @@ -6,8 +6,11 @@ use shift_font as font; use crate::{ ShiftStore, StoreError, - stored_layer::{compress_glyph_layer, decompress_glyph_layer, parse_layer_compression}, - types::StoredGlyphLayer, + stored_layer::{ + MAX_LAYER_PAYLOAD_BYTES as MAX_LAYER_PAYLOAD_BYTES_USIZE, compress_glyph_layer, + decode_layer, decompress_glyph_layer, encode_layer, + }, + types::{LayerPayloadCompression, StoredLayerPayload}, workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, }; @@ -15,7 +18,7 @@ use crate::{ pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; pub const MAX_LAYER_READ_BATCH_DECODED_BYTES: u64 = 256 * 1024 * 1024; -const MAX_LAYER_PAYLOAD_BYTES: i64 = font::MAX_LAYER_PAYLOAD_BYTES as i64; +const MAX_LAYER_PAYLOAD_BYTES: i64 = MAX_LAYER_PAYLOAD_BYTES_USIZE as i64; /// Relational facts needed to locate a layer without reading its payload. #[derive(Clone, Debug, PartialEq)] @@ -199,7 +202,7 @@ impl DirectoryFacts { self.inner_format.clone(), )); } - parse_layer_compression(&self.compression)?; + LayerPayloadCompression::try_from(self.compression.as_str())?; if self.stored_byte_length != self.actual_stored_byte_length { return Err(StoreError::LayerDirectoryMismatch { layer_id: self.layer_id.clone(), @@ -238,15 +241,15 @@ impl DirectoryFacts { Ok(self.decoded_byte_length as u64) } - fn stored_layer(&self, payload: Vec) -> Result { + fn stored_layer(&self, payload: Vec) -> Result { let decoded_blake3: [u8; 32] = self.decoded_blake3.as_slice().try_into().map_err(|_| { StoreError::LayerDirectoryMismatch { layer_id: self.layer_id.clone(), detail: "decoded BLAKE3 is not 32 bytes".to_string(), } })?; - Ok(StoredGlyphLayer { - compression: parse_layer_compression(&self.compression)?, + Ok(StoredLayerPayload { + compression: self.compression.as_str().try_into()?, stored_byte_length: self.stored_byte_length as u64, decoded_byte_length: self.decoded_byte_length as u64, decoded_blake3, @@ -320,7 +323,7 @@ pub(crate) fn load_glyph_layer_from_conn( detail: "payload changed during bounded read".to_string(), })?; let decoded = decompress_glyph_layer(facts.layer_id.as_str(), facts.stored_layer(payload)?)?; - let layer = font::unpack_glyph_layer(&decoded)?; + let layer = decode_layer(&decoded)?; validate_directory_facts( layer_id, &facts.source_id, @@ -524,7 +527,7 @@ fn load_glyph_layer_batch_from_conn( detail: "payload has no directory row".to_string(), })?; let decoded = decompress_glyph_layer(layer_id.as_str(), facts.stored_layer(payload)?)?; - let layer = font::unpack_glyph_layer(&decoded)?; + let layer = decode_layer(&decoded)?; let expected_id = font::LayerId::from_raw(layer_id.clone()); validate_directory_facts( &expected_id, @@ -594,8 +597,8 @@ pub(crate) fn write_layer_in_tx( glyph_id: &font::GlyphId, layer: &font::GlyphLayer, ) -> Result<(), StoreError> { - let packed = font::pack_glyph_layer(layer)?; - let stored = compress_glyph_layer(packed.as_bytes())?; + let encoded = encode_layer(layer)?; + let stored = compress_glyph_layer(&encoded)?; store_stored_layer_in_tx(tx, glyph_id, layer, &stored, WriteMode::Upsert) } @@ -603,7 +606,7 @@ pub(crate) fn store_stored_layer_in_tx( tx: &Transaction<'_>, glyph_id: &font::GlyphId, layer: &font::GlyphLayer, - stored: &StoredGlyphLayer, + stored: &StoredLayerPayload, mode: WriteMode, ) -> Result<(), StoreError> { let stored_byte_length = encoded_len(stored.stored_byte_length)?; @@ -863,9 +866,16 @@ mod tests { } #[test] - fn sqlite_payload_matches_shared_canonical_golden_bytes() { - let golden = include_bytes!("../../../fixtures/glyph-codec/layer-v1/full.bin"); - let layer = font::unpack_glyph_layer(golden).unwrap(); + fn sqlite_payload_preserves_canonical_bytes() { + let font = font::test_support::sample_font(); + let layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .next() + .unwrap(); + let encoded = encode_layer(layer).unwrap(); let glyph_id = font::GlyphId::from_raw("storage_golden"); let glyph_name = font::GlyphName::from("storageGolden"); let mut store = ShiftStore::open_memory_for_test().unwrap(); @@ -885,7 +895,7 @@ mod tests { .unwrap(); let tx = store.conn.transaction().unwrap(); - write_layer_in_tx(&tx, &glyph_id, &layer).unwrap(); + write_layer_in_tx(&tx, &glyph_id, layer).unwrap(); tx.commit().unwrap(); let stored = store @@ -896,7 +906,7 @@ mod tests { FROM glyph_layer_payloads WHERE layer_id = ?1", [layer.id().to_string()], |row| { - Ok(StoredGlyphLayer { + Ok(StoredLayerPayload { compression: match row.get::<_, String>(0)?.as_str() { "zstd.v1" => crate::types::LayerPayloadCompression::ZstandardV1, _ => crate::types::LayerPayloadCompression::None, @@ -916,10 +926,10 @@ mod tests { assert!(stored.stored_byte_length < stored.decoded_byte_length); assert_eq!( decompress_glyph_layer(layer.id().as_str(), stored).unwrap(), - golden + encoded ); let decoded = store.load_glyph_layer(&layer.id()).unwrap().unwrap(); - assert_eq!(font::pack_glyph_layer(&decoded).unwrap().as_bytes(), golden); + assert_eq!(encode_layer(&decoded).unwrap(), encoded); } #[test] @@ -1015,8 +1025,8 @@ mod tests { .collect::>(); layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); let uncompressed_layer = font.layer(layer_ids[0].clone()).unwrap(); - let packed = font::pack_glyph_layer(uncompressed_layer).unwrap(); - let decoded_blake3 = blake3::hash(packed.as_bytes()); + let encoded = encode_layer(uncompressed_layer).unwrap(); + let decoded_blake3 = blake3::hash(&encoded); store .conn .execute( @@ -1026,8 +1036,8 @@ mod tests { decoded_blake3 = ?3 WHERE layer_id = ?4", params![ - packed.as_bytes(), - packed.as_bytes().len() as i64, + encoded.as_slice(), + encoded.len() as i64, decoded_blake3.as_bytes().as_slice(), layer_ids[0].to_string(), ], @@ -1096,17 +1106,29 @@ mod tests { .values() .next() .unwrap(); - let packed = font::pack_glyph_layer(layer).unwrap(); - let uncompressed = StoredGlyphLayer { + let mut compressible_layer = layer.as_ref().clone(); + compressible_layer.lib_mut().set( + "com.shift.compression-fixture".to_string(), + font::LibValue::Data(vec![0; 8 * 1024]), + ); + let encoded = encode_layer(&compressible_layer).unwrap(); + let uncompressed = StoredLayerPayload { compression: crate::types::LayerPayloadCompression::None, - bytes: packed.as_bytes().to_vec(), - stored_byte_length: packed.as_bytes().len() as u64, - decoded_byte_length: packed.as_bytes().len() as u64, - decoded_blake3: *blake3::hash(packed.as_bytes()).as_bytes(), + bytes: encoded.clone(), + stored_byte_length: encoded.len() as u64, + decoded_byte_length: encoded.len() as u64, + decoded_blake3: *blake3::hash(&encoded).as_bytes(), }; let glyph_id = font.glyph_id_by_layer(layer.id()).unwrap(); let tx = store.conn.transaction().unwrap(); - store_stored_layer_in_tx(&tx, &glyph_id, layer, &uncompressed, WriteMode::Upsert).unwrap(); + store_stored_layer_in_tx( + &tx, + &glyph_id, + &compressible_layer, + &uncompressed, + WriteMode::Upsert, + ) + .unwrap(); tx.commit().unwrap(); assert_eq!( store @@ -1120,7 +1142,7 @@ mod tests { "none" ); - store.replace_glyph_layer(layer).unwrap(); + store.replace_glyph_layer(&compressible_layer).unwrap(); let (compression, stored, decoded, hash_length, payload_length) = store .conn @@ -1146,7 +1168,7 @@ mod tests { assert_eq!(hash_length, 32); assert_eq!( store.load_glyph_layer(&layer.id()).unwrap().unwrap(), - **layer + compressible_layer ); } @@ -1176,14 +1198,14 @@ mod tests { if detail.contains("decoded byte length") )); - let packed = font::pack_glyph_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); + let encoded = encode_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); store .conn .execute( "UPDATE glyph_layer_payloads SET decoded_byte_length = ?1, decoded_blake3 = zeroblob(32) WHERE layer_id = ?2", - params![packed.as_bytes().len() as i64, layer_id.to_string()], + params![encoded.len() as i64, layer_id.to_string()], ) .unwrap(); assert!(matches!( @@ -1426,7 +1448,7 @@ mod tests { let (mut store, font) = populated_store(); let before = store.list_glyph_layer_directory().unwrap(); { - let mut writer = store.font_stream_writer(&font).unwrap(); + let mut writer = store.begin_import(&font).unwrap(); let glyph = font.glyphs().next().unwrap(); writer .write_glyph_batch(std::slice::from_ref(glyph)) diff --git a/crates/shift-store/src/stored_layer.rs b/crates/shift-store/src/stored_layer.rs index 543b906c..36db44b3 100644 --- a/crates/shift-store/src/stored_layer.rs +++ b/crates/shift-store/src/stored_layer.rs @@ -1,12 +1,16 @@ -use std::cell::RefCell; +use std::{cell::RefCell, collections::HashSet, io::Cursor}; -use shift_font as font; +use serde::Deserialize; +use shift_font::{self as font, GlyphEntityId, LibValue}; use crate::{ StoreError, - types::{LayerPayloadCompression, StoredGlyphLayer}, + types::{LayerPayloadCompression, StoredLayerPayload}, }; +pub(crate) const MAX_LAYER_PAYLOAD_BYTES: usize = 256 * 1024 * 1024; +const MAX_NESTING_DEPTH: usize = 64; +const MAX_LIB_VALUES: usize = 1_000_000; const ZSTD_COMPRESSION_LEVEL: i32 = 1; thread_local! { @@ -14,7 +18,145 @@ thread_local! { static ZSTD_DECOMPRESSOR: RefCell>> = const { RefCell::new(None) }; } -pub(crate) fn compress_glyph_layer(decoded: &[u8]) -> Result { +pub(crate) fn encode_layer(layer: &font::GlyphLayer) -> Result, StoreError> { + validate_layer(layer).map_err(StoreError::InvalidLayerPayload)?; + let bytes = + rmp_serde::to_vec(layer).map_err(|error| StoreError::LayerEncoding(error.to_string()))?; + check_layer_length(bytes.len() as u64)?; + Ok(bytes) +} + +pub(crate) fn decode_layer(bytes: &[u8]) -> Result { + check_layer_length(bytes.len() as u64)?; + let mut decoder = rmp_serde::Deserializer::new(Cursor::new(bytes)); + decoder.set_max_depth(MAX_NESTING_DEPTH); + let layer = font::GlyphLayer::deserialize(&mut decoder) + .map_err(|error| StoreError::LayerDecoding(error.to_string()))?; + let consumed = decoder.position() as usize; + if consumed != bytes.len() { + return Err(StoreError::LayerDecoding(format!( + "payload has {} trailing bytes", + bytes.len() - consumed + ))); + } + validate_layer(&layer).map_err(StoreError::InvalidLayerPayload)?; + Ok(layer) +} + +fn validate_layer(layer: &font::GlyphLayer) -> Result<(), String> { + validate_finite(layer.width(), "width")?; + if let Some(height) = layer.height() { + validate_finite(height, "height")?; + } + + let mut entity_ids = HashSet::new(); + for contour in layer.contours_iter() { + record_entity(&mut entity_ids, contour.id().into())?; + for point in contour.points() { + record_entity(&mut entity_ids, point.id().into())?; + validate_finite(point.x(), "point x")?; + validate_finite(point.y(), "point y")?; + } + } + + for component in layer.components_iter() { + record_entity(&mut entity_ids, component.id().into())?; + let transform = component.transform(); + for (value, field) in [ + (transform.translate_x, "component translate x"), + (transform.translate_y, "component translate y"), + (transform.rotation, "component rotation"), + (transform.scale_x, "component scale x"), + (transform.scale_y, "component scale y"), + (transform.skew_x, "component skew x"), + (transform.skew_y, "component skew y"), + (transform.t_center_x, "component center x"), + (transform.t_center_y, "component center y"), + ] { + validate_finite(value, field)?; + } + } + + for anchor in layer.anchors_iter() { + record_entity(&mut entity_ids, anchor.id().into())?; + validate_finite(anchor.x(), "anchor x")?; + validate_finite(anchor.y(), "anchor y")?; + } + + for guideline in layer.guidelines() { + record_entity(&mut entity_ids, guideline.id().into())?; + for (value, field) in [ + (guideline.x(), "guideline x"), + (guideline.y(), "guideline y"), + (guideline.angle(), "guideline angle"), + ] { + if let Some(value) = value { + validate_finite(value, field)?; + } + } + } + + let mut lib_values = 0; + for value in layer.lib().iter().map(|(_, value)| value) { + validate_lib_value(value, 0, &mut lib_values)?; + } + Ok(()) +} + +fn record_entity( + entity_ids: &mut HashSet, + entity_id: GlyphEntityId, +) -> Result<(), String> { + if entity_ids.insert(entity_id.clone()) { + return Ok(()); + } + + let (kind, value) = match &entity_id { + GlyphEntityId::Contour(id) => ("contour", id.as_str()), + GlyphEntityId::Point(id) => ("point", id.as_str()), + GlyphEntityId::Component(id) => ("component", id.as_str()), + GlyphEntityId::Anchor(id) => ("anchor", id.as_str()), + GlyphEntityId::Guideline(id) => ("guideline", id.as_str()), + }; + Err(format!("duplicate {kind} ID {value:?}")) +} + +fn validate_lib_value(value: &LibValue, depth: usize, count: &mut usize) -> Result<(), String> { + if depth >= MAX_NESTING_DEPTH { + return Err("layer lib is nested too deeply".to_string()); + } + *count += 1; + if *count > MAX_LIB_VALUES { + return Err("layer lib has too many values".to_string()); + } + + match value { + LibValue::Float(value) => validate_finite(*value, "layer lib float"), + LibValue::Array(values) => { + for value in values { + validate_lib_value(value, depth + 1, count)?; + } + Ok(()) + } + LibValue::Dict(values) => { + for value in values.values() { + validate_lib_value(value, depth + 1, count)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn validate_finite(value: f64, field: &str) -> Result<(), String> { + if value.is_finite() { + return Ok(()); + } + + Err(format!("non-finite {field}")) +} + +pub(crate) fn compress_glyph_layer(decoded: &[u8]) -> Result { check_layer_length(decoded.len() as u64)?; let compressed = ZSTD_COMPRESSOR.with(|slot| { let mut slot = slot.borrow_mut(); @@ -33,7 +175,7 @@ pub(crate) fn compress_glyph_layer(decoded: &[u8]) -> Result Result Result Result, StoreError> { check_layer_length(stored.stored_byte_length)?; check_layer_length(stored.decoded_byte_length)?; @@ -87,7 +229,7 @@ pub(crate) fn decompress_glyph_layer( let decoded_capacity = usize::try_from(stored.decoded_byte_length).map_err(|_| { StoreError::LayerPayloadTooLarge { bytes: stored.decoded_byte_length, - limit: font::MAX_LAYER_PAYLOAD_BYTES as u64, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, } })?; ZSTD_DECOMPRESSOR.with(|slot| { @@ -122,16 +264,8 @@ pub(crate) fn decompress_glyph_layer( Ok(decoded) } -pub(crate) fn parse_layer_compression(value: &str) -> Result { - match value { - "none" => Ok(LayerPayloadCompression::None), - "zstd.v1" => Ok(LayerPayloadCompression::ZstandardV1), - value => Err(StoreError::UnsupportedLayerCompression(value.to_owned())), - } -} - fn check_layer_length(bytes: u64) -> Result<(), StoreError> { - let limit = font::MAX_LAYER_PAYLOAD_BYTES as u64; + let limit = MAX_LAYER_PAYLOAD_BYTES as u64; if bytes > limit { return Err(StoreError::LayerPayloadTooLarge { bytes, limit }); } @@ -147,8 +281,182 @@ fn directory_mismatch(layer_id: &str, detail: impl Into) -> StoreError { #[cfg(test)] mod tests { + use std::collections::BTreeMap; + use super::*; + #[test] + fn complete_layer_roundtrips_through_deterministic_bytes() { + let font = font::test_support::sample_font(); + let layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .find(|layer| !layer.lib().is_empty()) + .unwrap(); + + let encoded = encode_layer(layer).unwrap(); + let decoded = decode_layer(&encoded).unwrap(); + + assert_eq!(&decoded, layer.as_ref()); + assert_eq!(encode_layer(&decoded).unwrap(), encoded); + } + + #[test] + fn map_insertion_order_does_not_change_bytes() { + let font = font::test_support::sample_font(); + let mut layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .next() + .unwrap() + .as_ref() + .clone(); + let mut nested = BTreeMap::new(); + for index in (0..100).rev() { + layer.lib_mut().set( + format!("root-{index:03}"), + LibValue::Integer(i64::from(index)), + ); + nested.insert( + format!("nested-{index:03}"), + LibValue::Integer(i64::from(index)), + ); + } + layer.lib_mut().set("nested".into(), LibValue::Dict(nested)); + + let first = encode_layer(&layer).unwrap(); + let decoded = decode_layer(&first).unwrap(); + + assert_eq!(encode_layer(&decoded).unwrap(), first); + } + + #[test] + fn truncations_and_trailing_bytes_are_rejected() { + let font = font::test_support::sample_font(); + let layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .next() + .unwrap(); + let complete = encode_layer(layer).unwrap(); + for length in 0..complete.len() { + assert!( + decode_layer(&complete[..length]).is_err(), + "accepted truncation at {length}" + ); + } + + let mut trailing = complete; + trailing.push(0); + assert!(decode_layer(&trailing).is_err()); + } + + #[test] + fn hostile_declared_collection_length_is_rejected() { + let mut bytes = vec![0x99, 0xa7]; + bytes.extend_from_slice(b"layer_x"); + bytes.push(0xa8); + bytes.extend_from_slice(b"source_x"); + bytes.extend_from_slice(&[0xcb, 0, 0, 0, 0, 0, 0, 0, 0]); + bytes.push(0xc0); + bytes.extend_from_slice(&[0xdd, 0xff, 0xff, 0xff, 0xff]); + + assert!(decode_layer(&bytes).is_err()); + } + + #[test] + fn excessive_nesting_is_rejected_before_encode_and_during_decode() { + let font = font::test_support::sample_font(); + let mut layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .next() + .unwrap() + .as_ref() + .clone(); + let mut value = LibValue::Boolean(true); + for _ in 0..MAX_NESTING_DEPTH + 1 { + value = LibValue::Array(vec![value]); + } + layer.lib_mut().set("nested".into(), value); + + assert!(encode_layer(&layer).is_err()); + let encoded = rmp_serde::to_vec(&layer).unwrap(); + assert!(decode_layer(&encoded).is_err()); + } + + #[test] + fn invalid_ids_and_non_finite_values_are_rejected() { + let invalid_id = rmp_serde::to_vec(&( + "wrong", + "source_test", + 0.0_f64, + None::, + Vec::::new(), + Vec::::new(), + Vec::::new(), + Vec::::new(), + font::LibData::new(), + )) + .unwrap(); + assert!(decode_layer(&invalid_id).is_err()); + + let non_finite = rmp_serde::to_vec(&( + "layer_test", + "source_test", + f64::NAN, + None::, + Vec::::new(), + Vec::::new(), + Vec::::new(), + Vec::::new(), + font::LibData::new(), + )) + .unwrap(); + assert!(matches!( + decode_layer(&non_finite), + Err(StoreError::InvalidLayerPayload(_)) + )); + } + + #[test] + fn duplicate_point_identity_is_rejected() { + let point_id = font::PointId::from_raw("duplicate"); + let mut contour = font::Contour::new(); + contour.push_point(font::Point::new( + point_id.clone(), + 0.0, + 0.0, + font::PointType::OnCurve, + false, + )); + contour.push_point(font::Point::new( + point_id, + 1.0, + 1.0, + font::PointType::OnCurve, + false, + )); + let mut layer = font::GlyphLayer::new( + font::LayerId::from_raw("test"), + font::SourceId::from_raw("test"), + ); + layer.add_contour(contour); + + assert!(matches!( + encode_layer(&layer), + Err(StoreError::InvalidLayerPayload(_)) + )); + } + #[test] fn incompressible_bytes_fall_back_to_none() { let mut value = 0x1234_5678_u32; @@ -190,12 +498,12 @@ mod tests { #[test] fn unsupported_compression_and_oversized_decode_are_rejected() { assert!(matches!( - parse_layer_compression("zstd.v2"), + LayerPayloadCompression::try_from("zstd.v2"), Err(StoreError::UnsupportedLayerCompression(_)) )); let mut stored = compress_glyph_layer(&vec![0; 4096]).unwrap(); - stored.decoded_byte_length = font::MAX_LAYER_PAYLOAD_BYTES as u64 + 1; + stored.decoded_byte_length = MAX_LAYER_PAYLOAD_BYTES as u64 + 1; assert!(matches!( decompress_glyph_layer("layer_test", stored), Err(StoreError::LayerPayloadTooLarge { .. }) diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/stream_writer.rs index 61c9ceb8..cbd56aa4 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/stream_writer.rs @@ -5,12 +5,12 @@ use rusqlite::Transaction; use shift_font as font; use crate::{ - LayerBatchTiming, PackedGlyphBatch, ShiftStore, StoreError, + GlyphWriteBatch, LayerBatchTiming, ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, packed_layer::store_stored_layer_in_tx, schema::{defer_import_indexes, restore_import_indexes}, - stored_layer::compress_glyph_layer, - types::{PackedGlyph, StoredGlyphLayer}, + stored_layer::{compress_glyph_layer, encode_layer}, + types::{EncodedGlyphLayers, PackedGlyph, StoredLayerPayload}, write_mode::WriteMode, }; @@ -19,35 +19,35 @@ use crate::{ /// A foreign reader can write one glyph batch at a time without building a /// complete [`font::Font`]. The transaction commits only when [`Self::finish`] /// is called; dropping the writer rolls the entire stream back. -pub struct LayerStreamWriter<'store> { +pub struct FontImportWriter<'store> { tx: Transaction<'store>, next_glyph_order: i64, } impl ShiftStore { /// Starts an atomic replacement using a glyph-free font header. - pub fn font_stream_writer( + pub fn begin_import( &mut self, font_header: &font::Font, - ) -> Result, StoreError> { + ) -> Result, StoreError> { let tx = self.conn.transaction()?; replace_font_header_in_tx(&tx, font_header)?; defer_import_indexes(&tx)?; - Ok(LayerStreamWriter { + Ok(FontImportWriter { tx, next_glyph_order: 0, }) } } -/// Packs an owned bounded glyph batch in parallel without touching SQLite. +/// Encodes and compresses an owned bounded glyph batch without touching SQLite. /// The returned batch retains glyph directory facts for the single writer. -pub fn pack_glyph_batch(glyphs: Vec) -> Result { +pub fn encode_glyph_batch(glyphs: Vec) -> Result { let started = Instant::now(); - let packed_layers = pack_glyph_layers(&glyphs)?; + let encoded_layers = encode_glyph_layers(&glyphs)?; let pack_elapsed = started.elapsed(); let started = Instant::now(); - let layers = compress_glyph_layers(packed_layers)?; + let layers = compress_glyph_layers(encoded_layers)?; let compression_elapsed = started.elapsed(); let glyphs = glyphs .into_iter() @@ -55,30 +55,30 @@ pub fn pack_glyph_batch(glyphs: Vec) -> Result { +impl FontImportWriter<'_> { /// Writes one complete glyph and all of its currently authored layers. pub fn write_glyph(&mut self, glyph: &font::Glyph) -> Result<(), StoreError> { self.write_glyph_batch(std::slice::from_ref(glyph))?; Ok(()) } - /// Packs a bounded glyph batch in parallel, then writes it in stable order. + /// Encodes a bounded glyph batch in parallel, then writes it in stable order. pub fn write_glyph_batch( &mut self, glyphs: &[font::Glyph], ) -> Result { let started = Instant::now(); - let packed_layers = pack_glyph_layers(glyphs)?; + let encoded_layers = encode_glyph_layers(glyphs)?; let pack_elapsed = started.elapsed(); let started = Instant::now(); - let layers = compress_glyph_layers(packed_layers)?; + let layers = compress_glyph_layers(encoded_layers)?; let compression_elapsed = started.elapsed(); let started = Instant::now(); @@ -93,10 +93,10 @@ impl LayerStreamWriter<'_> { }) } - /// Writes a pre-packed batch in stable order through the owned transaction. + /// Writes a prepared batch in stable order through the owned transaction. pub fn write_packed_glyph_batch( &mut self, - batch: PackedGlyphBatch, + batch: GlyphWriteBatch, ) -> Result { let started = Instant::now(); for glyph in &batch.glyphs { @@ -108,7 +108,7 @@ impl LayerStreamWriter<'_> { fn write_packed_glyph( &mut self, glyph: &font::Glyph, - layers: &[(font::LayerId, StoredGlyphLayer)], + layers: &[(font::LayerId, StoredLayerPayload)], ) -> Result<(), StoreError> { write_glyph_directory_in_tx(&self.tx, glyph, self.next_glyph_order, WriteMode::Insert)?; for (layer_id, stored) in layers { @@ -133,32 +133,29 @@ impl LayerStreamWriter<'_> { } } -fn pack_glyph_layers( - glyphs: &[font::Glyph], -) -> Result>, StoreError> { +fn encode_glyph_layers(glyphs: &[font::Glyph]) -> Result { glyphs .par_iter() .map(|glyph| { glyph .layers() .values() - .map(|layer| font::pack_glyph_layer(layer).map(|packed| (layer.id(), packed))) + .map(|layer| encode_layer(layer).map(|encoded| (layer.id(), encoded))) .collect::, _>>() }) - .collect::, font::PackedLayerError>>() - .map_err(Into::into) + .collect() } fn compress_glyph_layers( - layers: Vec>, -) -> Result>, StoreError> { + layers: Vec)>>, +) -> Result>, StoreError> { layers .into_par_iter() .map(|layers| { layers .into_iter() - .map(|(layer_id, packed)| { - compress_glyph_layer(packed.as_bytes()).map(|stored| (layer_id, stored)) + .map(|(layer_id, encoded)| { + compress_glyph_layer(&encoded).map(|stored| (layer_id, stored)) }) .collect::, _>>() }) @@ -187,7 +184,7 @@ mod tests { assert!(!index_exists(&store.conn, "glyph_components_layer_id_idx")); { - let writer = store.font_stream_writer(&font).unwrap(); + let writer = store.begin_import(&font).unwrap(); for name in DEFERRED_INDEXES { assert!(!index_exists(&writer.tx, name)); } @@ -196,7 +193,7 @@ mod tests { assert!(index_exists(&store.conn, name)); } - let mut writer = store.font_stream_writer(&font).unwrap(); + let mut writer = store.begin_import(&font).unwrap(); for glyph in font.glyphs() { writer.write_glyph(glyph).unwrap(); } diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index 488d4d61..6796fc09 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -2,6 +2,10 @@ use std::time::Duration; use shift_font::{Glyph, LayerId}; +use crate::StoreError; + +pub(crate) type EncodedGlyphLayers = Vec)>>; + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct LayerBatchTiming { pub pack_elapsed: Duration, @@ -9,13 +13,13 @@ pub struct LayerBatchTiming { pub sqlite_elapsed: Duration, } -pub struct PackedGlyphBatch { +pub struct GlyphWriteBatch { pub(crate) glyphs: Vec, pub(crate) pack_elapsed: Duration, pub(crate) compression_elapsed: Duration, } -impl PackedGlyphBatch { +impl GlyphWriteBatch { pub fn pack_elapsed(&self) -> Duration { self.pack_elapsed } @@ -40,7 +44,19 @@ impl LayerPayloadCompression { } } -pub(crate) struct StoredGlyphLayer { +impl TryFrom<&str> for LayerPayloadCompression { + type Error = StoreError; + + fn try_from(value: &str) -> Result { + match value { + "none" => Ok(Self::None), + "zstd.v1" => Ok(Self::ZstandardV1), + value => Err(StoreError::UnsupportedLayerCompression(value.to_owned())), + } + } +} + +pub(crate) struct StoredLayerPayload { pub(crate) compression: LayerPayloadCompression, pub(crate) bytes: Vec, pub(crate) stored_byte_length: u64, @@ -50,7 +66,7 @@ pub(crate) struct StoredGlyphLayer { pub(crate) struct PackedGlyph { pub(crate) glyph: Glyph, - pub(crate) layers: Vec<(LayerId, StoredGlyphLayer)>, + pub(crate) layers: Vec<(LayerId, StoredLayerPayload)>, } #[derive(Clone, Debug, Eq, PartialEq, Hash)] diff --git a/crates/shift-store/tests/store_test.rs b/crates/shift-store/tests/store_test.rs index 47623f96..52d0e0d8 100644 --- a/crates/shift-store/tests/store_test.rs +++ b/crates/shift-store/tests/store_test.rs @@ -1009,7 +1009,7 @@ fn completed_import_store_restores_durable_wal_mode() { let path = temp_store_path("import-pragmas"); let font = shift_font::test_support::sample_font(); let mut store = ShiftStore::open_for_import(&path).expect("open import store"); - let mut writer = store.font_stream_writer(&font).expect("begin import"); + let mut writer = store.begin_import(&font).expect("begin import"); for glyph in font.glyphs() { writer.write_glyph(glyph).expect("write glyph"); } diff --git a/crates/shift-workspace/docs/DOCS.md b/crates/shift-workspace/docs/DOCS.md index 52c690c2..98118897 100644 --- a/crates/shift-workspace/docs/DOCS.md +++ b/crates/shift-workspace/docs/DOCS.md @@ -6,7 +6,7 @@ Backend runtime object for an open Shift font workspace. - **Architecture Invariant:** `FontWorkspace` composes a directory-complete, payload-lazy `shift-font::Font`, the user-selected `shift-source` package, and the working `shift-store` database. - **Architecture Invariant:** Resuming SQLite loads metadata and glyph/layer directory facts only. `acquire_glyphs` performs explicit bounded payload I/O; synchronous `font()` reads never initiate I/O. -- **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `LayerStreamWriter`; they never construct a complete geometry-resident `Font`. Format readers, canonical packing, BLAKE3 hashing, and independent per-layer compression use Rayon, while SQLite has one transaction owner. +- **Architecture Invariant:** TTF/OTF, UFO, and Designspace imports consume bounded `FontImport` batches and write through one `FontImportWriter`; they never construct a complete geometry-resident `Font`. Format readers, MessagePack encoding, BLAKE3 hashing, and independent per-layer compression use Rayon, while SQLite has one transaction owner. - **Architecture Invariant:** `LayerResidency` is the sole owner of loaded-layer membership and placeholder replacement. A loaded layer is only a cache of already-committed authored state; apply, undo, and redo reacquire their complete layer read sets before mutation, and `evict_glyphs` replaces only committed layers with directory placeholders. - **Architecture Invariant:** The `.shift` source package path and SQLite working store path are separate inputs. - **Architecture Invariant:** Package recovery policy is not ranked in Rust. `FontWorkspace` exposes package and working-store inspection primitives; the utility process owns binding and lifecycle decisions. @@ -58,7 +58,7 @@ crates/shift-workspace/examples/ ## Profiling -`profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, canonical pack, compression, SQLite write, commit, durable-finalization, native-directory materialization, reopen, decoded/stored BLOB, and database measurements without putting machine-specific timing assertions in tests: +`profile_streaming_import` uses the same public `stream_into` three-stage pipeline as the workspace and reports foreign-directory, parse, MessagePack encode, compression, SQLite write, commit, durable-finalization, native-directory materialization, reopen, decoded/stored BLOB, and database measurements without putting machine-specific timing assertions in tests: ```bash cargo build --release -p shift-workspace --example profile_streaming_import @@ -66,7 +66,7 @@ cargo build --release -p shift-workspace --example profile_streaming_import /path/to/font-or-project /tmp/import.sqlite ``` -Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse, pack, compression, SQLite, wall time, and throughput. Canonical packing, hashing, and compression use Rayon; the SQLite writer remains single-threaded. +Use `RAYON_NUM_THREADS`, `SHIFT_IMPORT_BATCH_GLYPHS`, and `SHIFT_IMPORT_BATCH_LAYERS` to compare worker and bounded-batch limits. `SHIFT_IMPORT_PROGRESS_BATCHES` controls machine-readable periodic lines containing cumulative batches, glyphs, layers, parse, pack, compression, SQLite, wall time, and throughput. MessagePack encoding, hashing, and compression use Rayon; the SQLite writer remains single-threaded. A repeatable ignored corpus gate exercises streaming import, directory-only resume, and bounded acquisition without checking large fonts into Git: diff --git a/crates/shift-workspace/examples/profile_streaming_import.rs b/crates/shift-workspace/examples/profile_streaming_import.rs index 7a30916c..56c40137 100644 --- a/crates/shift-workspace/examples/profile_streaming_import.rs +++ b/crates/shift-workspace/examples/profile_streaming_import.rs @@ -51,7 +51,7 @@ fn main() -> Result<(), Box> { let started = Instant::now(); let mut store = ShiftStore::open_for_import(&store_path)?; - let mut writer = store.font_stream_writer(import.header())?; + let mut writer = store.begin_import(import.header())?; println!("store_setup_ms={:.3}", ms(started.elapsed())); let pipeline_started = Instant::now(); diff --git a/crates/shift-workspace/src/import_pipeline.rs b/crates/shift-workspace/src/import_pipeline.rs index fd3812cb..67f4a2f2 100644 --- a/crates/shift-workspace/src/import_pipeline.rs +++ b/crates/shift-workspace/src/import_pipeline.rs @@ -2,7 +2,7 @@ use std::time::{Duration, Instant}; use shift_backends::{FontImport, ImportBatchLimit}; use shift_font::Glyph; -use shift_store::{LayerStreamWriter, PackedGlyphBatch, pack_glyph_batch}; +use shift_store::{FontImportWriter, GlyphWriteBatch, encode_glyph_batch}; use crate::WorkspaceError; @@ -24,7 +24,7 @@ struct ParsedImportBatch { } struct PackedImportBatch { - glyphs: PackedGlyphBatch, + glyphs: GlyphWriteBatch, glyph_count: usize, layer_count: usize, parse_elapsed: Duration, @@ -36,7 +36,7 @@ struct PackedImportBatch { /// must remain lightweight so it does not throttle the writer. pub fn stream_into( mut import: FontImport, - writer: &mut LayerStreamWriter<'_>, + writer: &mut FontImportWriter<'_>, batch_limit: ImportBatchLimit, mut observe: impl FnMut(ImportBatchProgress), ) -> Result<(), WorkspaceError> { @@ -76,7 +76,7 @@ pub fn stream_into( break; } }; - let glyphs = match pack_glyph_batch(batch.glyphs) { + let glyphs = match encode_glyph_batch(batch.glyphs) { Ok(glyphs) => glyphs, Err(error) => { let _ = packed_sender.send(Err(WorkspaceError::from(error))); diff --git a/crates/shift-workspace/src/import_staging.rs b/crates/shift-workspace/src/import_staging.rs index e59c6294..eb074279 100644 --- a/crates/shift-workspace/src/import_staging.rs +++ b/crates/shift-workspace/src/import_staging.rs @@ -1,4 +1,8 @@ -use std::{io, path::Path}; +use std::{ + ffi::OsString, + io, + path::{Path, PathBuf}, +}; use tempfile::TempPath; @@ -15,10 +19,28 @@ pub(crate) fn create_import_staging_path(destination: &Path) -> io::Result io::Result<()> { + remove_staging_sidecars(&staged)?; staged.persist(destination).map_err(|error| error.error)?; sync_parent_directory(destination) } +fn remove_staging_sidecars(staged: &Path) -> io::Result<()> { + for suffix in ["-wal", "-shm"] { + match std::fs::remove_file(sqlite_sidecar_path(staged, suffix)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + +fn sqlite_sidecar_path(path: &Path, suffix: &str) -> PathBuf { + let mut sidecar = OsString::from(path.as_os_str()); + sidecar.push(suffix); + PathBuf::from(sidecar) +} + #[cfg(unix)] pub(crate) fn sync_parent_directory(path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() @@ -45,11 +67,17 @@ mod tests { std::fs::write(&destination, b"old").unwrap(); let staged = create_import_staging_path(&destination).unwrap(); let staged_path = staged.to_path_buf(); + let staged_wal_path = sqlite_sidecar_path(&staged_path, "-wal"); + let staged_shm_path = sqlite_sidecar_path(&staged_path, "-shm"); std::fs::write(&staged_path, b"new").unwrap(); + std::fs::write(&staged_wal_path, b"").unwrap(); + std::fs::write(&staged_shm_path, b"").unwrap(); install_import_store(staged, &destination).unwrap(); assert_eq!(std::fs::read(&destination).unwrap(), b"new"); assert!(!staged_path.exists()); + assert!(!staged_wal_path.exists()); + assert!(!staged_shm_path.exists()); } } diff --git a/crates/shift-workspace/src/workspace.rs b/crates/shift-workspace/src/workspace.rs index 805ebf07..aa6d7469 100644 --- a/crates/shift-workspace/src/workspace.rs +++ b/crates/shift-workspace/src/workspace.rs @@ -763,7 +763,7 @@ impl FontWorkspace { let staged_path = create_import_staging_path(store_path)?; let mut store = ShiftStore::open_for_import(&staged_path)?; - let mut writer = store.font_stream_writer(import.header())?; + let mut writer = store.begin_import(import.header())?; stream_into(import, &mut writer, ImportBatchLimit::default(), |_| {})?; writer.finish()?; store.set_workspace_state(WorkspaceState::imported(import_path, None))?; diff --git a/crates/shift-workspace/tests/workspace_test.rs b/crates/shift-workspace/tests/workspace_test.rs index f420d822..509cdc30 100644 --- a/crates/shift-workspace/tests/workspace_test.rs +++ b/crates/shift-workspace/tests/workspace_test.rs @@ -145,13 +145,16 @@ fn imports_external_fonts_without_a_save_target() { .family_name .is_some() ); - assert!(fs::read_dir(temp.path()).unwrap().all(|entry| { - !entry - .unwrap() - .file_name() - .to_string_lossy() - .starts_with(".shift-import-") - })); + let entries = fs::read_dir(temp.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert!( + entries + .iter() + .all(|name| !name.to_string_lossy().starts_with(".shift-import-")), + "staging artifacts remain: {entries:?}" + ); } #[test] @@ -194,12 +197,7 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { .unwrap() .layers() .values() - .map(|layer| { - ( - layer.id(), - shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), - ) - }) + .map(|layer| (layer.id(), layer.as_ref().clone())) .collect::>() }) .collect::>(); @@ -215,13 +213,8 @@ fn large_ttf_reopens_directory_first_and_acquires_only_requested_layers() { assert_eq!(resumed.loaded_layer_count(), expected.len()); assert!(resumed.loaded_layer_count() < total_layers); - for (layer_id, bytes) in expected { - assert_eq!( - shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) - .unwrap() - .as_bytes(), - bytes - ); + for (layer_id, layer) in expected { + assert_eq!(resumed.font().layer(layer_id).unwrap(), &layer); } } @@ -337,12 +330,7 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { .unwrap() .layers() .values() - .map(|layer| { - ( - layer.id(), - shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), - ) - }) + .map(|layer| (layer.id(), layer.as_ref().clone())) .collect::>(); drop(workspace); @@ -354,13 +342,8 @@ fn designspace_and_ufo_sources_roundtrip_through_lazy_component_acquisition() { assert_eq!(resumed.loaded_layer_count(), closure_layer_count); assert!(resumed.loaded_layer_count() < total_layers); - for (layer_id, bytes) in expected_root_layers { - assert_eq!( - shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) - .unwrap() - .as_bytes(), - bytes - ); + for (layer_id, layer) in expected_root_layers { + assert_eq!(resumed.font().layer(layer_id).unwrap(), &layer); } } @@ -388,12 +371,7 @@ fn ufo_source_roundtrips_through_lazy_acquisition() { .unwrap() .layers() .values() - .map(|layer| { - ( - layer.id(), - shift_font::pack_glyph_layer(layer).unwrap().into_bytes(), - ) - }) + .map(|layer| (layer.id(), layer.as_ref().clone())) .collect::>(); assert!(!expected.is_empty()); drop(workspace); @@ -405,13 +383,8 @@ fn ufo_source_roundtrips_through_lazy_acquisition() { .unwrap(); assert_eq!(resumed.loaded_layer_count(), expected.len()); - for (layer_id, bytes) in expected { - assert_eq!( - shift_font::pack_glyph_layer(resumed.font().layer(layer_id).unwrap()) - .unwrap() - .as_bytes(), - bytes - ); + for (layer_id, layer) in expected { + assert_eq!(resumed.font().layer(layer_id).unwrap(), &layer); } } diff --git a/docs/architecture/index.md b/docs/architecture/index.md index ecd074ce..721d34a5 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -17,7 +17,6 @@ Central routing table for Shift's distributed documentation. Before creating new | --------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `crates/shift-backends/**` | [`crates/shift-backends/docs/DOCS.md`](../../crates/shift-backends/docs/DOCS.md) | Font format backends for reading/writing various font formats | | `crates/shift-font/**` | [`crates/shift-font/docs/DOCS.md`](../../crates/shift-font/docs/DOCS.md) | First-class Rust font object model and editing behavior | -| `crates/shift-glyph-codec/**` | [`crates/shift-glyph-codec/SPECIFICATION.md`](../../crates/shift-glyph-codec/SPECIFICATION.md) | Packed glyph framing, validation, and compatibility | | `crates/shift-slug/**` | [`crates/shift-slug/docs/DOCS.md`](../../crates/shift-slug/docs/DOCS.md) | GPU-independent Slug curves, bands, and atlas packing | | `crates/shift-source/**` | [`crates/shift-source/docs/DOCS.md`](../../crates/shift-source/docs/DOCS.md) | User-authored `.shift` source package layout | | `crates/shift-store/**` | [`crates/shift-store/README.md`](../../crates/shift-store/README.md) | SQLite directory, independently compressed canonical layer payloads, and reference indexes | @@ -51,7 +50,6 @@ Central routing table for Shift's distributed documentation. Before creating new | `packages/types/**` | [`packages/types/docs/DOCS.md`](../../packages/types/docs/DOCS.md) | Branded IDs, bridge DTO facade, and legacy editor migration types | | `packages/geo/**` | [`packages/geo/docs/DOCS.md`](../../packages/geo/docs/DOCS.md) | Geometry utilities (Vec2, Curve, Polygon, Mat) | | `packages/glyph-state/**` | [`packages/glyph-state/docs/DOCS.md`](../../packages/glyph-state/docs/DOCS.md) | Glyph-domain geometry (contour traversal, segment parsing, bounds) | -| `packages/glyph-codec/**` | [`packages/glyph-codec/README.md`](../../packages/glyph-codec/README.md) | DOM-free TypeScript packed glyph validation and views | | `packages/ui/**` | [`packages/ui/docs/DOCS.md`](../../packages/ui/docs/DOCS.md) | UI component library wrapping Base UI primitives | | `packages/validation/**` | [`packages/validation/docs/DOCS.md`](../../packages/validation/docs/DOCS.md) | Point sequence validation and persistence schemas | | `packages/rules/**` | [`packages/rules/docs/DOCS.md`](../../packages/rules/docs/DOCS.md) | Point editing rules engine for geometric constraints | diff --git a/fixtures/glyph-codec/layer-v1/empty.bin b/fixtures/glyph-codec/layer-v1/empty.bin deleted file mode 100644 index ea9ac0dd6bae539d7213f0bf9bd6c0a53ff4d08c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 113 zcmWIca0_8#WMC*{U|?VZVo4yz23VkiK&3bVH&6zoKPRy=wJ1I{x1gl5IKQ+gIW@j0 MHN7+^u?VCS0D8m>djJ3c diff --git a/fixtures/glyph-codec/layer-v1/full.bin b/fixtures/glyph-codec/layer-v1/full.bin deleted file mode 100644 index c3aa548442820fd768f1d9a656f145d0f44f3b67..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 945 zcmY+DJ8#rL6oqG(_ahJ<32#I}LC{9BO%hNF7$_nT5(T6ojWw|+UQ6B?Yt}}rC|Dta z#BZQMqNAgvfrSF`8z>MG5=ujZD4;Uu&e%(GwV%(qcOE+r``*o^<#vm)H`^FH%NU!2 zZ$ptXSnDZ&2`H#W*!b{-TYdV>>OmxDz(wGLE8rTq4OV~z3Oodlz;o~dyaKPmTksBi z1fRec@D+Rq8{jAS1^$8xOkg3j-b@Un39pAnCS@i}UzaA5O%`cYpb+Y^kS5ouDnieR z?<5%a$fb&OFV{+UF%1-|_!<5Gu)}Fh&ewQ3xOdFIbrMF3$$@%YLwUX>M_TVI@wZLS%VYJP=tabx$T@ zEz_dFE@UE2zc4B0q3xgRNjr@wRX^yhrT&>f!{-*Z;u~94IHf4X_JH6%d(Ote5g?+Bb*r;C9 zw{^ZBwF=>yX1G;;bud2b{P<3W7e*(MH`f3=JcS3vX+R$o I&DV_m2N4{$;Q#;t diff --git a/fixtures/glyph-codec/layer-v1/guideline-flags.bin b/fixtures/glyph-codec/layer-v1/guideline-flags.bin deleted file mode 100644 index 33487314de08f58408093e656f23c679de394aa9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 584 zcmZ`$O;5r=5FJ4gKd$@&6As1@st5@l9C|Rst8gww*M`_&(pnNv;LU$s-Y{$iNO;Zb zdo%mGyWM>ozKkj-M8gWvH4*)Thp3}*x@{tA;3Zo@)If0qw!jYf48DS6Z~}gT3-Aw= zb<+6PWl>VhK3qDRyXZ5GlRW<}vUe9{E-BJDv%z*e&E5RR8iCn+vkzuFW*^OV&Gv%3 zHl6^#PF1i3AV(o*AQ^$3Lz53bINQm|-23RVuWK+mTUpBbid$CgZI$R84WIM(pqbtG sfaRWiPWGT^v3DC?4fq1a=VTB2=(E-#U&2W43MzY01g!d`x(YYC1|9KwC;$Ke diff --git a/fixtures/glyph-codec/layer-v1/lib-only.bin b/fixtures/glyph-codec/layer-v1/lib-only.bin deleted file mode 100644 index 279a3192d35f628cd49b139fc91edbc6a91cc972..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 223 zcmWIca0_8#WMH_@z`(!;#HK)u4Y)xCfl84<12TsX$dLhJMIcrOVqG8xnU|ATnOYQ| zlbIBspO;fvoL^d$oEl%0nqHcdSd^MuP*RzfT3nKvQkIxgnp&KjT9TNOSdz#D)Cj_? jKnw!RKn!vq$N?-s3=#*4fdDd_fkD%O9ViCU%kUonWvUy$ diff --git a/fixtures/glyph-codec/layer-v1/vectors.json b/fixtures/glyph-codec/layer-v1/vectors.json deleted file mode 100644 index d0d4328b..00000000 --- a/fixtures/glyph-codec/layer-v1/vectors.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "format": "shift.glyph-layer.v1", - "vectors": [ - { - "file": "empty.bin", - "description": "Empty layer with absent height and empty lib", - "bytes": 113, - "sha256": "87863431c162f63bedfb5a0790bae4f67d09a5eb924c22046fc199d8efb16edb" - }, - { - "file": "full.bin", - "description": "All point types, closure states, identity-bearing entities, optional fields, transforms, and lib value tags", - "bytes": 945, - "sha256": "1cc4172f22a4cd01682b8f328cc9df5e091c9ab143fff173ebdba0d32c7afc8f" - }, - { - "file": "lib-only.bin", - "description": "Nested layer lib values without authored geometry", - "bytes": 223, - "sha256": "db9930faa821b0e521d514898a198b2a26b9a03f5da37c8872b86f441774b190" - }, - { - "file": "guideline-flags.bin", - "description": "Every x, y, and angle presence-bit permutation", - "bytes": 584, - "sha256": "12855590657787565347a36eb0c03b24d3456917f33727f0231a738ba031ad90" - } - ] -} diff --git a/fixtures/glyph-codec/outline-v1/closed-line.bin b/fixtures/glyph-codec/outline-v1/closed-line.bin deleted file mode 100644 index 7c516978589e93073a331d1430ab4db1daa3fa86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36 icmWIca0_8%WME)sU|?VYVg^Pq31R`k2`2^y4`%=|;{w?L diff --git a/fixtures/glyph-codec/outline-v1/empty.bin b/fixtures/glyph-codec/outline-v1/empty.bin deleted file mode 100644 index 054a38009b4571994ee1c14222ef17c5079a5335..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16 PcmWIca0_8%WPk$z5vKt- diff --git a/fixtures/glyph-codec/outline-v1/multiple-contours.bin b/fixtures/glyph-codec/outline-v1/multiple-contours.bin deleted file mode 100644 index a81597ad8da5b47bfe98cf48fddadc7e01b3569d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 56 vcmWIca0_8%WME)rU|`??Vg^PQ1|Sy+C^$kmC!H7?P8?!j@Ni~enC1)sbe9Gt diff --git a/fixtures/glyph-codec/outline-v1/open.bin b/fixtures/glyph-codec/outline-v1/open.bin deleted file mode 100644 index d4479bba9226f1d551f5ed577c44459c461cec61..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36 ocmWIca0_8%WME)oU|?VYVg?|afx+V-14DxyL&J9`28Sny0Zn`crT_o{ diff --git a/fixtures/glyph-codec/outline-v1/quadratic-cubic.bin b/fixtures/glyph-codec/outline-v1/quadratic-cubic.bin deleted file mode 100644 index 2d091b34b24db5967f6c1c0dcf63a97d2ecdfccf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 68 zcmWIca0_8%WME)nU|`?@Vg@E=AoIX}1_l8KAkUtG!C^lGN0keMz^@}f_5lWl2|)1` PKz!rCn~JpwK>PpzZR-%E diff --git a/fixtures/glyph-codec/outline-v1/vectors.json b/fixtures/glyph-codec/outline-v1/vectors.json deleted file mode 100644 index 60b3c5ee..00000000 --- a/fixtures/glyph-codec/outline-v1/vectors.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "format": "shift.glyph-outline.v1", - "vectors": [ - { - "name": "empty", - "file": "empty.bin", - "bytes": 16, - "sha256": "ae4497afa69fec3f60441c2af7359117fb419fa9b473af219735b21359db3b1e", - "commands": [] - }, - { - "name": "closed-line", - "file": "closed-line.bin", - "bytes": 36, - "sha256": "ff508aa8468d3ef1057cb47a8d9b507f0c81b0385821edde9a199c5cac3ab7b5", - "commands": [ - { - "kind": "move", - "coordinates": [0, 0] - }, - { - "kind": "line", - "coordinates": [100, 200] - }, - { - "kind": "close", - "coordinates": [] - } - ] - }, - { - "name": "open", - "file": "open.bin", - "bytes": 36, - "sha256": "5115ef5e5b2249f877593011f9fa08359aef8cc7c90dee80a1568379091e059b", - "commands": [ - { - "kind": "move", - "coordinates": [-12.5, 0.25] - }, - { - "kind": "line", - "coordinates": [123.75, -456.5] - } - ] - }, - { - "name": "quadratic-cubic", - "file": "quadratic-cubic.bin", - "bytes": 68, - "sha256": "72fd95db7ed662d21cfa248d65ad8fdde1f3fd00efc524ee036fa449ac8797b6", - "commands": [ - { - "kind": "move", - "coordinates": [-1.5, 2.25] - }, - { - "kind": "quad", - "coordinates": [0.5, -0.75, 1000.125, -2000.5] - }, - { - "kind": "cubic", - "coordinates": [-3, 4.5, 5.25, -6.75, 100000000000000000000, -100000000000000000000] - }, - { - "kind": "close", - "coordinates": [] - } - ] - }, - { - "name": "multiple-contours", - "file": "multiple-contours.bin", - "bytes": 56, - "sha256": "f2e1e7b8666d5921f68f9d2d01caa3e7ec1b52712bf2b18bc7915e028788e748", - "commands": [ - { - "kind": "move", - "coordinates": [0, 0] - }, - { - "kind": "line", - "coordinates": [10, 0] - }, - { - "kind": "close", - "coordinates": [] - }, - { - "kind": "move", - "coordinates": [100.5, -100.25] - }, - { - "kind": "line", - "coordinates": [200, 300] - } - ] - } - ] -} diff --git a/packages/glyph-codec/README.md b/packages/glyph-codec/README.md deleted file mode 100644 index fd899dd4..00000000 --- a/packages/glyph-codec/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# @shift/glyph-codec - -DOM-free TypeScript implementation of Shift's packed glyph payload family. - -The normative byte layouts and compatibility rules live in -[`crates/shift-glyph-codec/SPECIFICATION.md`](../../crates/shift-glyph-codec/SPECIFICATION.md). - -The package owns: - -- strict `shift.glyph-outline.v1` validation and `PackedGlyphOutline`; -- strict, lossless `shift.glyph-layer.v1` validation and `PackedGlyphLayer`; -- canonical encoders shared with Rust golden vectors; -- iterable layer/contour views that avoid constructing a complete font. - -`PackedGlyphOutline` is flattened, derived `f32` rendering data. -`PackedGlyphLayer` is canonical authored `f64` state with stable identities, -point semantics, components, anchors, guidelines, and lib values. They are not -interchangeable. - -Transport, persistence, `Path2D`, SVG debugging, SQLite, and Slug/GPU -transformations belong to their respective consumers. Rust adaptation to the -editable object model lives in shift-font's private `codec_adapter`, keeping the codec -independent of font semantics. - -Implementation modules keep responsibilities narrow: `frame.ts` owns the common -frame, `layer-binary.ts` owns bounded byte access, `layer-strings.ts` owns the -canonical first-use string table, `layer-lib.ts` owns nested lib values, and -`layer.ts` owns layer framing and iterable views. Packing retains writer-derived -offsets rather than decoding its own output a second time. - -```ts -import { decodeLayer, packLayer } from "@shift/glyph-codec"; - -const packed = packLayer(layer); -for (const contour of packed.contours()) { - for (const point of contour.points()) { - // Build a bounded derived projection without materializing every layer. - } -} - -const validated = decodeLayer(bytes); -const editableLayer = validated.unpack(); -``` - -Verification: - -```bash -pnpm --filter @shift/glyph-codec typecheck -pnpm --filter @shift/glyph-codec test -cargo test -p shift-glyph-codec -cargo run --release -p shift-glyph-codec --example layer_benchmark -``` diff --git a/packages/glyph-codec/package.json b/packages/glyph-codec/package.json deleted file mode 100644 index 92994276..00000000 --- a/packages/glyph-codec/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "@shift/glyph-codec", - "version": "0.1.0", - "private": true, - "description": "Strict packed glyph payload codecs for Shift", - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { - ".": { - "types": "./src/index.ts", - "import": "./src/index.ts" - } - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:watch": "vitest" - }, - "devDependencies": { - "@types/node": "^25.3.0", - "typescript": "^5.5.4", - "vitest": "^4.0.17" - } -} diff --git a/packages/glyph-codec/src/error.ts b/packages/glyph-codec/src/error.ts deleted file mode 100644 index 4ec01091..00000000 --- a/packages/glyph-codec/src/error.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { GlyphCodecErrorCode } from "./types"; - -export class GlyphCodecError extends Error { - readonly code: GlyphCodecErrorCode; - - constructor(code: GlyphCodecErrorCode, message: string) { - super(message); - this.name = "GlyphCodecError"; - this.code = code; - } -} - -export function fail(code: GlyphCodecErrorCode, message: string): never { - throw new GlyphCodecError(code, message); -} diff --git a/packages/glyph-codec/src/fixtures.test.ts b/packages/glyph-codec/src/fixtures.test.ts deleted file mode 100644 index bbc72970..00000000 --- a/packages/glyph-codec/src/fixtures.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { decodeLayer, packLayer } from "./layer"; -import { decodeOutline, packOutline } from "./outline"; -import type { OutlineCommand } from "./types"; - -type BinaryVector = { - readonly file: string; - readonly bytes: number; - readonly sha256: string; -}; - -type LayerManifest = { - readonly format: string; - readonly vectors: readonly BinaryVector[]; -}; - -type OutlineManifest = { - readonly format: string; - readonly vectors: readonly OutlineVector[]; -}; - -type OutlineVector = BinaryVector & { - readonly name: string; - readonly commands: readonly ManifestCommand[]; -}; - -type ManifestCommand = { - readonly kind: string; - readonly coordinates: readonly number[]; -}; - -describe("canonical glyph-codec fixtures", () => { - it("declares every layer fixture's length, digest, and canonical bytes", () => { - const directory = fixtureUrl("layer-v1"); - const manifest = readJson(new URL("vectors.json", directory)); - expect(manifest.format).toBe("shift.glyph-layer.v1"); - expect(new Set(manifest.vectors.map((vector) => vector.file)).size).toBe( - manifest.vectors.length, - ); - - for (const vector of manifest.vectors) { - const bytes = readFileSync(new URL(vector.file, directory)); - expectBinaryFacts(bytes, vector); - expect([...packLayer(decodeLayer(bytes).unpack()).toUint8Array()]).toEqual([...bytes]); - } - }); - - it("declares every outline fixture's commands, length, digest, and canonical bytes", () => { - const directory = fixtureUrl("outline-v1"); - const manifest = readJson(new URL("vectors.json", directory)); - expect(manifest.format).toBe("shift.glyph-outline.v1"); - const vectors = manifest.vectors; - expect(new Set(vectors.map((vector) => vector.file)).size).toBe(vectors.length); - expect(new Set(vectors.map((vector) => vector.name)).size).toBe(vectors.length); - - for (const vector of vectors) { - const bytes = readFileSync(new URL(vector.file, directory)); - const commands = vector.commands.map(manifestCommand); - expectBinaryFacts(bytes, vector); - expect([...packOutline(commands).toUint8Array()]).toEqual([...bytes]); - expect(decodeOutline(bytes).commandCount).toBe(commands.length); - } - }); -}); - -function fixtureUrl(format: string): URL { - return new URL(`../../../fixtures/glyph-codec/${format}/`, import.meta.url); -} - -function readJson(url: URL): T { - return JSON.parse(readFileSync(url, "utf8")) as T; -} - -function expectBinaryFacts(bytes: Uint8Array, vector: BinaryVector): void { - expect(bytes.byteLength, `${vector.file} byte length`).toBe(vector.bytes); - expect(createHash("sha256").update(bytes).digest("hex"), `${vector.file} SHA-256`).toBe( - vector.sha256, - ); -} - -function manifestCommand(command: ManifestCommand): OutlineCommand { - switch (command.kind) { - case "move": - case "line": { - const [x, y] = command.coordinates; - if (command.coordinates.length !== 2) throw new Error(`invalid ${command.kind} fixture`); - return { kind: command.kind, x, y }; - } - case "quad": { - const [cx, cy, x, y] = command.coordinates; - if (command.coordinates.length !== 4) throw new Error("invalid quad fixture"); - return { kind: "quad", cx, cy, x, y }; - } - case "cubic": { - const [c1x, c1y, c2x, c2y, x, y] = command.coordinates; - if (command.coordinates.length !== 6) throw new Error("invalid cubic fixture"); - return { kind: "cubic", c1x, c1y, c2x, c2y, x, y }; - } - case "close": - if (command.coordinates.length !== 0) throw new Error("invalid close fixture"); - return { kind: "close" }; - default: - throw new Error(`unknown fixture command ${command.kind}`); - } -} diff --git a/packages/glyph-codec/src/frame.ts b/packages/glyph-codec/src/frame.ts deleted file mode 100644 index 9ab76be2..00000000 --- a/packages/glyph-codec/src/frame.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { fail } from "./error"; - -const MAGIC = [0x53, 0x48, 0x46, 0x54] as const; -export const FRAME_PREFIX_LENGTH = 8; - -type FrameSpec = { - readonly headerLength: number; - readonly kind: number; - readonly version: number; - readonly headerName: string; - readonly versionName: string; - readonly flagsName: string; -}; - -export function validateFrame(bytes: Uint8Array, spec: FrameSpec): void { - if (bytes.byteLength < spec.headerLength) { - fail( - "header-truncated", - `${spec.headerName} header is truncated: ${bytes.byteLength} of ${spec.headerLength} bytes`, - ); - } - if (MAGIC.some((byte, index) => bytes[index] !== byte)) { - fail("wrong-magic", "wrong glyph-codec magic"); - } - if (bytes[4] !== spec.kind) { - fail("unsupported-kind", `unsupported glyph-codec payload kind ${hex(bytes[4])}`); - } - if (bytes[5] !== spec.version) { - fail("unsupported-version", `unsupported ${spec.versionName} version ${bytes[5]}`); - } - - const flags = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint16(6, true); - if (flags !== 0) { - fail("unknown-flags", `unknown ${spec.flagsName} flags ${hex(flags, 4)}`); - } -} - -export function writeFrame(bytes: Uint8Array, kind: number, version: number): void { - if (bytes.byteLength < FRAME_PREFIX_LENGTH) { - throw new Error("glyph-codec frame buffer is too short"); - } - bytes.set(MAGIC, 0); - bytes[4] = kind; - bytes[5] = version; - new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint16(6, 0, true); -} - -function hex(value: number, width = 2): string { - return `0x${value.toString(16).padStart(width, "0")}`; -} diff --git a/packages/glyph-codec/src/index.ts b/packages/glyph-codec/src/index.ts deleted file mode 100644 index ccc078cd..00000000 --- a/packages/glyph-codec/src/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -export { GlyphCodecError } from "./error"; -export { - decodeLayer, - LayerContourView, - MAX_LAYER_CONTOUR_COUNT, - MAX_LAYER_ENTITY_COUNT, - MAX_LAYER_LIB_DEPTH, - MAX_LAYER_LIB_VALUES, - MAX_LAYER_PAYLOAD_BYTES, - MAX_LAYER_POINT_COUNT, - MAX_LAYER_STRING_BYTES, - MAX_LAYER_STRING_COUNT, - PackedGlyphLayer, - packLayer, -} from "./layer"; -export { - decodeOutline, - MAX_COMMAND_COUNT, - MAX_COORDINATE_COUNT, - MAX_PAYLOAD_BYTES, - PackedGlyphOutline, - packOutline, -} from "./outline"; -export type { - GlyphCodecErrorCode, - GlyphLayer, - LayerAnchor, - LayerComponent, - LayerContour, - LayerGuideline, - LayerLibValue, - LayerPoint, - LayerPointType, - LayerTransform, - OutlineCommand, -} from "./types"; diff --git a/packages/glyph-codec/src/layer-binary.ts b/packages/glyph-codec/src/layer-binary.ts deleted file mode 100644 index 7a0ef50c..00000000 --- a/packages/glyph-codec/src/layer-binary.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { fail } from "./error"; -import { validateFinite } from "./layer-limits"; - -export const MIN_I64 = -(1n << 63n); -export const MAX_I64 = (1n << 63n) - 1n; -export const MAX_U64 = (1n << 64n) - 1n; - -export class Cursor { - readonly #bytes: Uint8Array; - readonly #view: DataView; - offset: number; - end: number; - - constructor(bytes: Uint8Array, offset: number, end: number) { - this.#bytes = bytes; - this.#view = dataView(bytes); - this.offset = offset; - this.end = end; - } - - take(count: number): Uint8Array { - const next = checkedAdd(this.offset, count); - if (next > this.end) - fail( - "truncated", - `glyph-layer body is truncated at byte ${this.offset}: need ${count} bytes, have ${Math.max(0, this.end - this.offset)}`, - ); - const value = this.#bytes.subarray(this.offset, next); - this.offset = next; - return value; - } - - u8(): number { - const offset = this.offset; - this.take(1); - return this.#view.getUint8(offset); - } - u16(): number { - const offset = this.offset; - this.take(2); - return this.#view.getUint16(offset, true); - } - u32(): number { - const offset = this.offset; - this.take(4); - return this.#view.getUint32(offset, true); - } - i64(): bigint { - const offset = this.offset; - this.take(8); - return this.#view.getBigInt64(offset, true); - } - u64(): bigint { - const offset = this.offset; - this.take(8); - return this.#view.getBigUint64(offset, true); - } - f64(): number { - const offset = this.offset; - this.take(8); - return this.#view.getFloat64(offset, true); - } -} - -export class Writer { - readonly #bytes: Uint8Array; - readonly #view: DataView; - offset: number; - - constructor(bytes: Uint8Array, offset: number) { - this.#bytes = bytes; - this.#view = dataView(bytes); - this.offset = offset; - } - - u8(value: number): void { - this.#view.setUint8(this.offset, value); - this.offset += 1; - } - u16(value: number): void { - this.#view.setUint16(this.offset, value, true); - this.offset += 2; - } - u32(value: number): void { - this.#view.setUint32(this.offset, value, true); - this.offset += 4; - } - i64(value: bigint): void { - checkInteger(value, MIN_I64, MAX_I64); - this.#view.setBigInt64(this.offset, value, true); - this.offset += 8; - } - u64(value: bigint): void { - checkInteger(value, 0n, MAX_U64); - this.#view.setBigUint64(this.offset, value, true); - this.offset += 8; - } - f64(value: number): void { - validateFinite(value, "input number", 0); - this.#view.setFloat64(this.offset, value, true); - this.offset += 8; - } - bytes(value: Uint8Array): void { - this.#bytes.set(value, this.offset); - this.offset += value.byteLength; - } -} - -export function checkInteger(value: bigint, minimum: bigint, maximum: bigint): void { - if (value < minimum || value > maximum) - fail("integer-out-of-range", `integer ${value} is outside the encoded range`); -} - -export function requireRange(bytes: Uint8Array, offset: number, count: number): void { - const end = checkedAdd(offset, count); - if (end > bytes.byteLength) - fail( - "truncated", - `glyph-layer body is truncated at byte ${offset}: need ${count} bytes, have ${Math.max(0, bytes.byteLength - offset)}`, - ); -} - -export function checkedAdd(left: number, right: number): number { - const value = left + right; - if (!Number.isSafeInteger(value)) - fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); - return value; -} - -export function checkedMultiply(left: number, right: number): number { - const value = left * right; - if (!Number.isSafeInteger(value)) - fail("length-overflow", "glyph-layer payload length arithmetic overflowed"); - return value; -} - -export function dataView(bytes: Uint8Array): DataView { - return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); -} - -export function hex(value: number, width = 2): string { - return `0x${value.toString(16).padStart(width, "0")}`; -} diff --git a/packages/glyph-codec/src/layer-lib.ts b/packages/glyph-codec/src/layer-lib.ts deleted file mode 100644 index 57ee6186..00000000 --- a/packages/glyph-codec/src/layer-lib.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { fail } from "./error"; -import { - checkInteger, - checkedAdd, - Cursor, - hex, - MAX_I64, - MAX_U64, - MIN_I64, - Writer, -} from "./layer-binary"; -import { - checkLayerDepth, - checkLayerLimit, - MAX_LAYER_LIB_VALUES, - validateFinite, -} from "./layer-limits"; -import { compareUtf8, DecodeState, StringPool } from "./layer-strings"; -import type { LayerLibValue } from "./types"; - -const LIB_TAGS: Record = { - string: 0, - integer: 1, - "unsigned-integer": 2, - float: 3, - boolean: 4, - array: 5, - dict: 6, - data: 7, - date: 8, - uid: 9, -}; - -export function decodeMap( - cursor: Cursor, - state: DecodeState, - depth: number, -): ReadonlyMap { - checkLayerDepth(depth); - const count = cursor.u32(); - checkLayerLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); - const values = new Map(); - let previous: string | null = null; - for (let index = 0; index < count; index += 1) { - const keyOffset = cursor.offset; - const key = state.requiredString(cursor.u32()); - if (previous !== null && compareUtf8(previous, key) >= 0) - fail( - "non-canonical-map-order", - `dictionary keys are not in canonical UTF-8 order at byte ${keyOffset}`, - ); - previous = key; - values.set(key, decodeLibValue(cursor, state, depth + 1)); - } - return values; -} - -function decodeLibValue(cursor: Cursor, state: DecodeState, depth: number): LayerLibValue { - checkLayerDepth(depth); - state.countLibValue(); - const tagOffset = cursor.offset; - const tag = cursor.u8(); - if (cursor.u8() !== 0 || cursor.u16() !== 0) - fail("non-zero-reserved", `non-zero reserved lib value bytes at index ${state.libValues - 1}`); - switch (tag) { - case 0: - return { kind: "string", value: state.requiredString(cursor.u32()) }; - case 1: - return { kind: "integer", value: cursor.i64() }; - case 2: - return { kind: "unsigned-integer", value: cursor.u64() }; - case 3: { - const value = cursor.f64(); - validateFinite(value, "lib float", state.libValues - 1); - return { kind: "float", value }; - } - case 4: { - const offset = cursor.offset; - const value = cursor.u8(); - if (cursor.take(3).some((byte) => byte !== 0)) - fail( - "non-zero-reserved", - `non-zero reserved lib boolean bytes at index ${state.libValues - 1}`, - ); - if (value !== 0 && value !== 1) - fail("invalid-boolean", `invalid boolean ${value} at byte ${offset}`); - return { kind: "boolean", value: value === 1 }; - } - case 5: { - const count = cursor.u32(); - checkLayerLimit("lib value count", checkedAdd(state.libValues, count), MAX_LAYER_LIB_VALUES); - const value: LayerLibValue[] = []; - for (let index = 0; index < count; index += 1) - value.push(decodeLibValue(cursor, state, depth + 1)); - return { kind: "array", value }; - } - case 6: - return { kind: "dict", value: decodeMap(cursor, state, depth + 1) }; - case 7: - return { kind: "data", value: Uint8Array.from(cursor.take(cursor.u32())) }; - case 8: - return { kind: "date", value: state.requiredString(cursor.u32()) }; - case 9: - return { kind: "uid", value: cursor.u64() }; - default: - fail("unknown-lib-tag", `unknown lib value tag ${hex(tag)} at byte ${tagOffset}`); - } -} - -export function gatherMapStrings( - values: ReadonlyMap, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkLayerDepth(depth); - for (const [key, value] of sortedEntries(values)) { - strings.intern(key); - gatherValueStrings(value, strings, depth + 1, state); - } -} - -function gatherValueStrings( - value: LayerLibValue, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkLayerDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - switch (value.kind) { - case "string": - case "date": - strings.intern(value.value); - break; - case "array": - for (const item of value.value) gatherValueStrings(item, strings, depth + 1, state); - break; - case "dict": - gatherMapStrings(value.value, strings, depth + 1, state); - break; - } -} - -export function mapByteLength( - values: ReadonlyMap, - depth: number, - state: { count: number }, -): number { - checkLayerDepth(depth); - let length = 4; - for (const [, value] of sortedEntries(values)) - length = checkedAdd(length, checkedAdd(4, valueByteLength(value, depth + 1, state))); - return length; -} - -function valueByteLength(value: LayerLibValue, depth: number, state: { count: number }): number { - checkLayerDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - switch (value.kind) { - case "string": - case "date": - return 8; - case "integer": - checkInteger(value.value, MIN_I64, MAX_I64); - return 12; - case "unsigned-integer": - case "uid": - checkInteger(value.value, 0n, MAX_U64); - return 12; - case "float": - validateFinite(value.value, "lib float", state.count - 1); - return 12; - case "boolean": - return 8; - case "array": - return value.value.reduce( - (length, item) => checkedAdd(length, valueByteLength(item, depth + 1, state)), - 8, - ); - case "dict": - return checkedAdd(4, mapByteLength(value.value, depth + 1, state)); - case "data": - return checkedAdd(8, value.value.byteLength); - } -} - -export function encodeMap( - writer: Writer, - values: ReadonlyMap, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkLayerDepth(depth); - writer.u32(values.size); - for (const [key, value] of sortedEntries(values)) { - writer.u32(strings.index(key)); - encodeLibValue(writer, value, strings, depth + 1, state); - } -} - -function encodeLibValue( - writer: Writer, - value: LayerLibValue, - strings: StringPool, - depth: number, - state: { count: number }, -): void { - checkLayerDepth(depth); - state.count = checkedAdd(state.count, 1); - checkLayerLimit("lib value count", state.count, MAX_LAYER_LIB_VALUES); - writer.u8(LIB_TAGS[value.kind]); - writer.u8(0); - writer.u16(0); - switch (value.kind) { - case "string": - case "date": - writer.u32(strings.index(value.value)); - break; - case "integer": - writer.i64(value.value); - break; - case "unsigned-integer": - case "uid": - writer.u64(value.value); - break; - case "float": - writer.f64(value.value); - break; - case "boolean": - writer.u8(value.value ? 1 : 0); - writer.u8(0); - writer.u16(0); - break; - case "array": - writer.u32(value.value.length); - for (const item of value.value) encodeLibValue(writer, item, strings, depth + 1, state); - break; - case "dict": - encodeMap(writer, value.value, strings, depth + 1, state); - break; - case "data": - writer.u32(value.value.byteLength); - writer.bytes(value.value); - break; - } -} - -function sortedEntries( - values: ReadonlyMap, -): ReadonlyArray { - return [...values.entries()].sort((left, right) => compareUtf8(left[0], right[0])); -} diff --git a/packages/glyph-codec/src/layer-limits.ts b/packages/glyph-codec/src/layer-limits.ts deleted file mode 100644 index bf3888ad..00000000 --- a/packages/glyph-codec/src/layer-limits.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { fail } from "./error"; - -export const MAX_LAYER_CONTOUR_COUNT = 1_000_000; -export const MAX_LAYER_POINT_COUNT = 4_000_000; -export const MAX_LAYER_ENTITY_COUNT = 1_000_000; -export const MAX_LAYER_STRING_COUNT = 4_000_000; -export const MAX_LAYER_STRING_BYTES = 64 * 1024 * 1024; -export const MAX_LAYER_LIB_VALUES = 1_000_000; -export const MAX_LAYER_LIB_DEPTH = 64; -export const MAX_LAYER_PAYLOAD_BYTES = 256 * 1024 * 1024; - -export function checkLayerLimit(field: string, actual: number, limit: number): void { - if (actual > limit) - fail( - "limit-exceeded", - `glyph-layer ${field} exceeds implementation limit ${limit}: got ${actual}`, - ); -} - -export function checkLayerDepth(depth: number): void { - if (depth > MAX_LAYER_LIB_DEPTH) - fail( - "nesting-limit-exceeded", - `layer lib nesting exceeds implementation limit ${MAX_LAYER_LIB_DEPTH}`, - ); -} - -export function validateFinite(value: number, field: string, index: number): void { - if (!Number.isFinite(value)) - fail("non-finite-number", `non-finite ${field} value at index ${index}`); -} diff --git a/packages/glyph-codec/src/layer-strings.ts b/packages/glyph-codec/src/layer-strings.ts deleted file mode 100644 index 42c0e515..00000000 --- a/packages/glyph-codec/src/layer-strings.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { fail } from "./error"; -import { checkedAdd, dataView } from "./layer-binary"; -import { - checkLayerLimit, - MAX_LAYER_LIB_VALUES, - MAX_LAYER_STRING_BYTES, - MAX_LAYER_STRING_COUNT, -} from "./layer-limits"; - -export const NONE_STRING = 0xffff_ffff; -const textEncoder = new TextEncoder(); -const textDecoder = new TextDecoder("utf-8", { fatal: true }); - -export class StringPool { - readonly #indexes = new Map(); - readonly #values: string[] = []; - readonly #encoded: Uint8Array[] = []; - #byteLength = 0; - - get count(): number { - return this.#values.length; - } - get byteLength(): number { - return this.#byteLength; - } - get encodedValues(): readonly Uint8Array[] { - return this.#encoded; - } - get values(): readonly string[] { - return this.#values; - } - - intern(value: string): number { - const existing = this.#indexes.get(value); - if (existing !== undefined) return existing; - checkLayerLimit("string count", this.#values.length + 1, MAX_LAYER_STRING_COUNT); - const encoded = textEncoder.encode(value); - if (textDecoder.decode(encoded) !== value) - fail("invalid-utf8", "input string is not valid Unicode scalar text"); - this.#byteLength = checkedAdd(this.#byteLength, encoded.byteLength); - checkLayerLimit("string bytes", this.#byteLength, MAX_LAYER_STRING_BYTES); - const index = this.#values.length; - this.#values.push(value); - this.#encoded.push(encoded); - this.#indexes.set(value, index); - return index; - } - - index(value: string): number { - const index = this.#indexes.get(value); - if (index === undefined) throw new Error("strings must be gathered before encoding"); - return index; - } -} - -export class DecodeState { - readonly #strings: readonly string[]; - readonly #validateReferences: boolean; - readonly #identities = new Map>(); - nextString = 0; - pointIndex = 0; - libValues = 0; - - constructor(strings: readonly string[], validateReferences: boolean) { - this.#strings = strings; - this.#validateReferences = validateReferences; - } - - reference(index: number): void { - if (index >= this.#strings.length) - fail("string-reference-out-of-range", `string reference ${index} is out of range`); - if (!this.#validateReferences) return; - if (index > this.nextString) - fail( - "non-canonical-string-reference", - `string reference ${index} is out of canonical first-use order; expected at most ${this.nextString}`, - ); - if (index === this.nextString) this.nextString += 1; - } - - requiredString(index: number): string { - this.reference(index); - return this.#strings[index]; - } - - optionalString(index: number): string | null { - return index === NONE_STRING ? null : this.requiredString(index); - } - - uniqueIdentity(kind: string, index: number, value: string): void { - let identities = this.#identities.get(kind); - if (!identities) { - identities = new Set(); - this.#identities.set(kind, identities); - } - if (identities.has(value)) - fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); - identities.add(value); - } - - countLibValue(): void { - this.libValues = checkedAdd(this.libValues, 1); - checkLayerLimit("lib value count", this.libValues, MAX_LAYER_LIB_VALUES); - } -} - -export function validateStrings( - bytes: Uint8Array, - count: number, - expectedBytes: number, - offsetsOffset: number, - valuesOffset: number, -): readonly string[] { - const view = dataView(bytes); - if (view.getUint32(offsetsOffset, true) !== 0) - fail("invalid-string-offsets", "non-canonical string offsets at index 0"); - const strings: string[] = []; - const seen = new Set(); - let previous = 0; - for (let index = 0; index < count; index += 1) { - const end = view.getUint32(offsetsOffset + (index + 1) * 4, true); - if (end < previous || end > expectedBytes) - fail("invalid-string-offsets", `non-canonical string offsets at index ${index + 1}`); - let value: string; - try { - value = textDecoder.decode(bytes.subarray(valuesOffset + previous, valuesOffset + end)); - } catch { - fail("invalid-utf8", `invalid UTF-8 in string ${index}`); - } - if (seen.has(value)) fail("duplicate-string", `duplicate string-table value at index ${index}`); - seen.add(value); - strings.push(value); - previous = end; - } - if (previous !== expectedBytes) - fail("invalid-string-offsets", `non-canonical string offsets at index ${count}`); - return strings; -} - -export function compareUtf8(left: string, right: string): number { - const leftBytes = textEncoder.encode(left); - const rightBytes = textEncoder.encode(right); - const count = Math.min(leftBytes.length, rightBytes.length); - for (let index = 0; index < count; index += 1) { - if (leftBytes[index] !== rightBytes[index]) return leftBytes[index] - rightBytes[index]; - } - return leftBytes.length - rightBytes.length; -} diff --git a/packages/glyph-codec/src/layer.test.ts b/packages/glyph-codec/src/layer.test.ts deleted file mode 100644 index e77de943..00000000 --- a/packages/glyph-codec/src/layer.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { decodeLayer, GlyphCodecError, packLayer } from "./layer"; -import type { GlyphCodecErrorCode, GlyphLayer } from "./types"; - -function fullLayer(): GlyphLayer { - return { - id: "layer_A_regular", - sourceId: "source_regular", - width: 600.125, - height: 720.5, - contours: [ - { - id: "contour_outer", - closed: true, - points: [ - { id: "point_0", type: "on-curve", smooth: false, x: 100.25, y: -0 }, - { id: "point_1", type: "off-curve", smooth: true, x: 300.5, y: 700.75 }, - { id: "point_2", type: "qcurve", smooth: false, x: 500.875, y: 0 }, - ], - }, - { id: "contour_open", closed: false, points: [] }, - ], - components: [ - { - id: "component_acute", - baseGlyphId: "glyph_acute", - baseGlyphName: "acutecomb", - transform: { - translateX: 10, - translateY: 20, - rotation: 5.5, - scaleX: 1.25, - scaleY: 0.75, - skewX: 2, - skewY: -1.5, - centerX: 50, - centerY: 75, - }, - }, - ], - anchors: [ - { id: "anchor_top", name: "top", x: 300, y: 700 }, - { id: "anchor_unnamed", name: null, x: 12.5, y: -30.25 }, - ], - guidelines: [ - { - id: "guideline_baseline", - x: null, - y: 0, - angle: null, - name: "Baseline", - color: "0,0,1,1", - }, - { - id: "guideline_angle", - x: 100, - y: 200, - angle: 45, - name: null, - color: null, - }, - ], - lib: new Map([ - [ - "array", - { - kind: "array" as const, - value: [ - { kind: "boolean" as const, value: false }, - { kind: "boolean" as const, value: true }, - ], - }, - ], - ["data", { kind: "data" as const, value: Uint8Array.from([0, 1, 2, 255]) }], - ["date", { kind: "date" as const, value: "2026-07-25T12:34:56Z" }], - [ - "dict", - { - kind: "dict" as const, - value: new Map([ - ["alpha", { kind: "string" as const, value: "value" }], - ["omega", { kind: "uid" as const, value: 0xffff_ffff_ffff_ffffn }], - ]), - }, - ], - ["float", { kind: "float" as const, value: -12.25 }], - ["integer", { kind: "integer" as const, value: -0x8000_0000_0000_0000n }], - ["string", { kind: "string" as const, value: "layer metadata" }], - ["unsigned", { kind: "unsigned-integer" as const, value: 0xffff_ffff_ffff_ffffn }], - ]), - }; -} - -function emptyLayer(): GlyphLayer { - return { - id: "layer_empty", - sourceId: "source_regular", - width: 0, - height: null, - contours: [], - components: [], - anchors: [], - guidelines: [], - lib: new Map(), - }; -} - -function fixture(file: string): Uint8Array { - return readFileSync(new URL(`../../../fixtures/glyph-codec/layer-v1/${file}`, import.meta.url)); -} - -function errorCode(action: () => unknown): GlyphCodecErrorCode | null { - try { - action(); - return null; - } catch (error) { - if (!(error instanceof GlyphCodecError)) throw error; - return error.code; - } -} - -function stringSectionEnd(bytes: Uint8Array): number { - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - return 72 + (view.getUint32(12, true) + 1) * 4 + view.getUint32(16, true); -} - -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - return left.length === right.length && left.every((value, index) => value === right[index]); -} - -describe("shift.glyph-layer.v1", () => { - it("shares byte-for-byte golden vectors with Rust", () => { - const expected = fullLayer(); - const full = fixture("full.bin"); - expect(decodeLayer(full).unpack()).toEqual(expected); - expect([...packLayer(expected).toUint8Array()]).toEqual([...full]); - - const empty = fixture("empty.bin"); - expect(decodeLayer(empty).unpack()).toEqual(emptyLayer()); - expect([...packLayer(emptyLayer()).toUint8Array()]).toEqual([...empty]); - }); - - it("roundtrips the complete authored model losslessly", () => { - const expected = fullLayer(); - const packed = packLayer(expected); - const decoded = packed.unpack(); - - expect(packed.id).toBe(expected.id); - expect(packed.sourceId).toBe(expected.sourceId); - expect(packed.width).toBe(expected.width); - expect(packed.height).toBe(expected.height); - expect(packed.contourCount).toBe(2); - expect(packed.pointCount).toBe(3); - expect(packed.componentCount).toBe(1); - expect(packed.anchorCount).toBe(2); - expect(packed.guidelineCount).toBe(2); - expect(decoded).toEqual(expected); - expect(Object.is(decoded.contours[0].points[0].y, -0)).toBe(true); - }); - - it("streams one contour and entity at a time", () => { - const packed = packLayer(fullLayer()); - const contours = [...packed.contours()]; - - expect(contours[0].id).toBe("contour_outer"); - expect(contours[0].closed).toBe(true); - expect([...contours[0].points()].map((point) => [point.id, point.type])).toEqual([ - ["point_0", "on-curve"], - ["point_1", "off-curve"], - ["point_2", "qcurve"], - ]); - expect([...packed.components()][0].baseGlyphId).toBe("glyph_acute"); - expect([...packed.anchors()][1].name).toBeNull(); - expect([...packed.guidelines()][0].y).toBe(0); - }); - - it("encodes dictionaries canonically regardless of insertion order", () => { - const first = fullLayer(); - const second = { ...fullLayer(), lib: new Map([...fullLayer().lib].reverse()) }; - - expect([...packLayer(first).toUint8Array()]).toEqual([...packLayer(second).toUint8Array()]); - }); - - it("orders dictionary keys by UTF-8 bytes across the BMP boundary", () => { - const layer: GlyphLayer = { - ...emptyLayer(), - lib: new Map([ - ["\u{10000}", { kind: "string", value: "astral" }], - ["\uffff", { kind: "string", value: "bmp" }], - ]), - }; - - const decoded = decodeLayer(packLayer(layer).toUint8Array()).unpack(); - - expect([...decoded.lib.keys()]).toEqual(["\uffff", "\u{10000}"]); - }); - - it("does not expose mutable validated storage", () => { - const source = packLayer(fullLayer()).toUint8Array(); - const layer = decodeLayer(source); - const exported = layer.toUint8Array(); - source[0] = 0; - exported[1] = 0; - - expect(layer.id).toBe("layer_A_regular"); - expect(layer.pointCount).toBe(3); - }); -}); - -describe("strict layer decoding", () => { - it("rejects every truncation and trailing bytes", () => { - const bytes = packLayer(fullLayer()).toUint8Array(); - for (let length = 0; length < bytes.length; length += 1) { - expect(errorCode(() => decodeLayer(bytes.slice(0, length)))).not.toBeNull(); - } - - expect(errorCode(() => decodeLayer(Uint8Array.from([...bytes, 0])))).toBe("length-mismatch"); - }); - - it("rejects framing, unknown flags and point types", () => { - const seed = packLayer(fullLayer()).toUint8Array(); - const cases: ReadonlyArray = [ - [0, 0, "wrong-magic"], - [4, 1, "unsupported-kind"], - [5, 2, "unsupported-version"], - [6, 1, "unknown-flags"], - [52, 2, "unknown-layer-flags"], - [stringSectionEnd(seed) + 12 + 4, 3, "unknown-point-type"], - ]; - - for (const [offset, value, code] of cases) { - const bytes = seed.slice(); - bytes[offset] = value; - expect(errorCode(() => decodeLayer(bytes))).toBe(code); - } - }); - - it("rejects non-canonical absent numeric slots", () => { - const bytes = packLayer(emptyLayer()).toUint8Array(); - bytes[71] = 0x80; - - expect(errorCode(() => decodeLayer(bytes))).toBe("non-canonical-absent-number"); - }); - - it("rejects non-finite numbers, duplicate identity, and out-of-range integers", () => { - const source = fullLayer(); - const [firstContour, ...otherContours] = source.contours; - const [firstPoint, secondPoint, ...otherPoints] = firstContour.points; - const nonfinite: GlyphLayer = { - ...source, - contours: [ - { - ...firstContour, - points: [{ ...firstPoint, x: Number.NaN }, secondPoint, ...otherPoints], - }, - ...otherContours, - ], - }; - expect(errorCode(() => packLayer(nonfinite))).toBe("non-finite-number"); - - const duplicate: GlyphLayer = { - ...source, - contours: [ - { - ...firstContour, - points: [firstPoint, { ...secondPoint, id: firstPoint.id }, ...otherPoints], - }, - ...otherContours, - ], - }; - expect(errorCode(() => packLayer(duplicate))).toBe("duplicate-identity"); - - const integer: GlyphLayer = { - ...source, - lib: new Map([["too-large", { kind: "uid", value: 1n << 64n }]]), - }; - expect(errorCode(() => packLayer(integer))).toBe("integer-out-of-range"); - }); - - it("never accepts a mutated non-canonical payload", () => { - const seed = packLayer(fullLayer()).toUint8Array(); - let random = 0x81d2_67a5; - - for (let iteration = 0; iteration < 10_000; iteration += 1) { - random ^= random << 13; - random ^= random >>> 17; - random ^= random << 5; - const candidate = seed.slice(); - const offset = (random >>> 0) % candidate.length; - candidate[offset] ^= ((random >>> 24) | 1) & 0xff; - const mutated = - iteration % 7 === 0 - ? candidate.slice(0, (random >>> 0) % candidate.length) - : iteration % 11 === 0 - ? Uint8Array.from([...candidate, (random >>> 16) & 0xff]) - : candidate; - - try { - const layer = decodeLayer(mutated); - if (!bytesEqual(packLayer(layer.unpack()).toUint8Array(), mutated)) { - throw new Error("decoder accepted a non-canonical layer encoding"); - } - } catch (error) { - if (!(error instanceof GlyphCodecError)) throw error; - } - } - }); -}); diff --git a/packages/glyph-codec/src/layer.ts b/packages/glyph-codec/src/layer.ts deleted file mode 100644 index 9536672f..00000000 --- a/packages/glyph-codec/src/layer.ts +++ /dev/null @@ -1,721 +0,0 @@ -import { fail } from "./error"; -import { validateFrame, writeFrame } from "./frame"; -import { - checkedAdd, - checkedMultiply, - Cursor, - dataView, - hex, - requireRange, - Writer, -} from "./layer-binary"; -import { decodeMap, encodeMap, gatherMapStrings, mapByteLength } from "./layer-lib"; -import { - checkLayerLimit as checkLimit, - MAX_LAYER_CONTOUR_COUNT, - MAX_LAYER_ENTITY_COUNT, - MAX_LAYER_PAYLOAD_BYTES, - MAX_LAYER_POINT_COUNT, - MAX_LAYER_STRING_BYTES, - MAX_LAYER_STRING_COUNT, - validateFinite, -} from "./layer-limits"; -import { DecodeState, NONE_STRING, StringPool, validateStrings } from "./layer-strings"; -import type { - GlyphLayer, - LayerAnchor, - LayerComponent, - LayerContour, - LayerGuideline, - LayerLibValue, - LayerPoint, - LayerPointType, - LayerTransform, -} from "./types"; - -export { GlyphCodecError } from "./error"; -export { - MAX_LAYER_CONTOUR_COUNT, - MAX_LAYER_ENTITY_COUNT, - MAX_LAYER_LIB_DEPTH, - MAX_LAYER_LIB_VALUES, - MAX_LAYER_PAYLOAD_BYTES, - MAX_LAYER_POINT_COUNT, - MAX_LAYER_STRING_BYTES, - MAX_LAYER_STRING_COUNT, -} from "./layer-limits"; - -const LAYER_KIND = 0x02; -const LAYER_VERSION = 0x01; -const HEADER_LENGTH = 72; -const CONTOUR_LENGTH = 12; -const POINT_LENGTH = 24; -const COMPONENT_LENGTH = 88; -const ANCHOR_LENGTH = 24; -const GUIDELINE_LENGTH = 40; - -type Header = { - readonly strings: readonly string[]; - readonly stringCount: number; - readonly contourCount: number; - readonly pointCount: number; - readonly componentCount: number; - readonly anchorCount: number; - readonly guidelineCount: number; - readonly idIndex: number; - readonly sourceIdIndex: number; - readonly width: number; - readonly height: number | null; - readonly contoursOffset: number; - readonly componentsOffset: number; - readonly anchorsOffset: number; - readonly guidelinesOffset: number; - readonly libOffset: number; - readonly libEnd: number; -}; - -/** One contour backed by validated packed bytes. */ -export class LayerContourView { - readonly #bytes: Uint8Array; - readonly #view: DataView; - readonly #strings: readonly string[]; - readonly #pointsOffset: number; - readonly id: string; - readonly closed: boolean; - readonly pointCount: number; - - constructor( - bytes: Uint8Array, - view: DataView, - strings: readonly string[], - id: string, - closed: boolean, - pointCount: number, - pointsOffset: number, - ) { - this.#bytes = bytes; - this.#view = view; - this.#strings = strings; - this.#pointsOffset = pointsOffset; - this.id = id; - this.closed = closed; - this.pointCount = pointCount; - } - - *points(): IterableIterator { - let offset = this.#pointsOffset; - for (let index = 0; index < this.pointCount; index += 1) { - yield { - id: this.#strings[this.#view.getUint32(offset, true)], - type: pointTypeFromByte(this.#bytes[offset + 4], index), - smooth: (this.#bytes[offset + 5] & 1) !== 0, - x: this.#view.getFloat64(offset + 8, true), - y: this.#view.getFloat64(offset + 16, true), - }; - offset += POINT_LENGTH; - } - } - - unpack(): LayerContour { - return { id: this.id, closed: this.closed, points: [...this.points()] }; - } -} - -/** - * Opaque, immutable, validated `shift.glyph-layer.v1` bytes. - * - * Decoding copies caller-owned bytes. Iterators decode one authored entity at a - * time and never construct a complete font or retain unrelated layers. - */ -export class PackedGlyphLayer { - readonly #bytes: Uint8Array; - readonly #view: DataView; - readonly #header: Header; - - private constructor(bytes: Uint8Array, header: Header) { - this.#bytes = bytes; - this.#view = dataView(bytes); - this.#header = header; - } - - static decode(data: Uint8Array): PackedGlyphLayer { - checkLimit("payload byte length", data.byteLength, MAX_LAYER_PAYLOAD_BYTES); - const bytes = Uint8Array.from(data); - return new PackedGlyphLayer(bytes, validate(bytes)); - } - - static pack(layer: GlyphLayer): PackedGlyphLayer { - const [bytes, header] = encode(layer); - return new PackedGlyphLayer(bytes, header); - } - - get byteLength(): number { - return this.#bytes.byteLength; - } - - get id(): string { - return this.#header.strings[this.#header.idIndex]; - } - - get sourceId(): string { - return this.#header.strings[this.#header.sourceIdIndex]; - } - - get width(): number { - return this.#header.width; - } - - get height(): number | null { - return this.#header.height; - } - - get contourCount(): number { - return this.#header.contourCount; - } - - get pointCount(): number { - return this.#header.pointCount; - } - - get componentCount(): number { - return this.#header.componentCount; - } - - get anchorCount(): number { - return this.#header.anchorCount; - } - - get guidelineCount(): number { - return this.#header.guidelineCount; - } - - toUint8Array(): Uint8Array { - return this.#bytes.slice(); - } - - *contours(): IterableIterator { - let offset = this.#header.contoursOffset; - for (let index = 0; index < this.#header.contourCount; index += 1) { - const pointCount = this.#view.getUint32(offset + 4, true); - const pointsOffset = offset + CONTOUR_LENGTH; - yield new LayerContourView( - this.#bytes, - this.#view, - this.#header.strings, - this.#header.strings[this.#view.getUint32(offset, true)], - (this.#view.getUint32(offset + 8, true) & 1) !== 0, - pointCount, - pointsOffset, - ); - offset = pointsOffset + pointCount * POINT_LENGTH; - } - } - - *components(): IterableIterator { - let offset = this.#header.componentsOffset; - for (let index = 0; index < this.#header.componentCount; index += 1) { - yield { - id: this.#header.strings[this.#view.getUint32(offset, true)], - baseGlyphId: this.#header.strings[this.#view.getUint32(offset + 4, true)], - baseGlyphName: this.#header.strings[this.#view.getUint32(offset + 8, true)], - transform: readTransform(this.#view, offset + 16), - }; - offset += COMPONENT_LENGTH; - } - } - - *anchors(): IterableIterator { - let offset = this.#header.anchorsOffset; - for (let index = 0; index < this.#header.anchorCount; index += 1) { - yield { - id: this.#header.strings[this.#view.getUint32(offset, true)], - name: optionalString(this.#header.strings, this.#view.getUint32(offset + 4, true)), - x: this.#view.getFloat64(offset + 8, true), - y: this.#view.getFloat64(offset + 16, true), - }; - offset += ANCHOR_LENGTH; - } - } - - *guidelines(): IterableIterator { - let offset = this.#header.guidelinesOffset; - for (let index = 0; index < this.#header.guidelineCount; index += 1) { - const flags = this.#view.getUint32(offset + 12, true); - yield { - id: this.#header.strings[this.#view.getUint32(offset, true)], - name: optionalString(this.#header.strings, this.#view.getUint32(offset + 4, true)), - color: optionalString(this.#header.strings, this.#view.getUint32(offset + 8, true)), - x: (flags & 1) === 0 ? null : this.#view.getFloat64(offset + 16, true), - y: (flags & 2) === 0 ? null : this.#view.getFloat64(offset + 24, true), - angle: (flags & 4) === 0 ? null : this.#view.getFloat64(offset + 32, true), - }; - offset += GUIDELINE_LENGTH; - } - } - - lib(): ReadonlyMap { - const cursor = new Cursor(this.#bytes, this.#header.libOffset, this.#header.libEnd); - return decodeMap(cursor, new DecodeState(this.#header.strings, false), 0); - } - - unpack(): GlyphLayer { - return { - id: this.id, - sourceId: this.sourceId, - width: this.width, - height: this.height, - contours: [...this.contours()].map((contour) => contour.unpack()), - components: [...this.components()], - anchors: [...this.anchors()], - guidelines: [...this.guidelines()], - lib: this.lib(), - }; - } -} - -export function packLayer(layer: GlyphLayer): PackedGlyphLayer { - return PackedGlyphLayer.pack(layer); -} - -export function decodeLayer(data: Uint8Array): PackedGlyphLayer { - return PackedGlyphLayer.decode(data); -} - -function encode(layer: GlyphLayer): readonly [Uint8Array, Header] { - validateLayerInput(layer); - checkLimit("contour count", layer.contours.length, MAX_LAYER_CONTOUR_COUNT); - checkLimit("component count", layer.components.length, MAX_LAYER_ENTITY_COUNT); - checkLimit("anchor count", layer.anchors.length, MAX_LAYER_ENTITY_COUNT); - checkLimit("guideline count", layer.guidelines.length, MAX_LAYER_ENTITY_COUNT); - - let pointCount = 0; - for (const contour of layer.contours) { - pointCount = checkedAdd(pointCount, contour.points.length); - checkLimit("point count", pointCount, MAX_LAYER_POINT_COUNT); - } - - const strings = new StringPool(); - strings.intern(layer.id); - strings.intern(layer.sourceId); - for (const contour of layer.contours) { - strings.intern(contour.id); - for (const point of contour.points) strings.intern(point.id); - } - for (const component of layer.components) { - strings.intern(component.id); - strings.intern(component.baseGlyphId); - strings.intern(component.baseGlyphName); - } - for (const anchor of layer.anchors) { - strings.intern(anchor.id); - if (anchor.name !== null) strings.intern(anchor.name); - } - for (const guideline of layer.guidelines) { - strings.intern(guideline.id); - if (guideline.name !== null) strings.intern(guideline.name); - if (guideline.color !== null) strings.intern(guideline.color); - } - const libState = { count: 0 }; - gatherMapStrings(layer.lib, strings, 0, libState); - - const libBytes = mapByteLength(layer.lib, 0, { count: 0 }); - const contoursBytes = layer.contours.reduce( - (total, contour) => - checkedAdd( - total, - checkedAdd(CONTOUR_LENGTH, checkedMultiply(contour.points.length, POINT_LENGTH)), - ), - 0, - ); - const stringOffsetsBytes = checkedMultiply(strings.count + 1, 4); - const contoursOffset = checkedAdd( - checkedAdd(HEADER_LENGTH, stringOffsetsBytes), - strings.byteLength, - ); - const componentsOffset = checkedAdd(contoursOffset, contoursBytes); - const anchorsOffset = checkedAdd( - componentsOffset, - checkedMultiply(layer.components.length, COMPONENT_LENGTH), - ); - const guidelinesOffset = checkedAdd( - anchorsOffset, - checkedMultiply(layer.anchors.length, ANCHOR_LENGTH), - ); - const libOffset = checkedAdd( - guidelinesOffset, - checkedMultiply(layer.guidelines.length, GUIDELINE_LENGTH), - ); - const byteLength = checkedAdd(libOffset, libBytes); - checkLimit("payload byte length", byteLength, MAX_LAYER_PAYLOAD_BYTES); - - const bytes = new Uint8Array(byteLength); - const view = dataView(bytes); - writeFrame(bytes, LAYER_KIND, LAYER_VERSION); - view.setUint32(8, byteLength, true); - view.setUint32(12, strings.count, true); - view.setUint32(16, strings.byteLength, true); - view.setUint32(20, layer.contours.length, true); - view.setUint32(24, pointCount, true); - view.setUint32(28, layer.components.length, true); - view.setUint32(32, layer.anchors.length, true); - view.setUint32(36, layer.guidelines.length, true); - view.setUint32(40, libBytes, true); - view.setUint32(44, strings.index(layer.id), true); - view.setUint32(48, strings.index(layer.sourceId), true); - view.setUint32(52, layer.height === null ? 0 : 1, true); - view.setFloat64(56, layer.width, true); - view.setFloat64(64, layer.height ?? 0, true); - - const writer = new Writer(bytes, HEADER_LENGTH); - writer.u32(0); - let stringOffset = 0; - for (const encoded of strings.encodedValues) { - stringOffset = checkedAdd(stringOffset, encoded.byteLength); - writer.u32(stringOffset); - } - for (const encoded of strings.encodedValues) writer.bytes(encoded); - - for (const contour of layer.contours) { - writer.u32(strings.index(contour.id)); - writer.u32(contour.points.length); - writer.u32(contour.closed ? 1 : 0); - for (const point of contour.points) { - writer.u32(strings.index(point.id)); - writer.u8(pointTypeByte(point.type)); - writer.u8(point.smooth ? 1 : 0); - writer.u16(0); - writer.f64(point.x); - writer.f64(point.y); - } - } - for (const component of layer.components) { - writer.u32(strings.index(component.id)); - writer.u32(strings.index(component.baseGlyphId)); - writer.u32(strings.index(component.baseGlyphName)); - writer.u32(0); - for (const value of transformValues(component.transform)) writer.f64(value); - } - for (const anchor of layer.anchors) { - writer.u32(strings.index(anchor.id)); - writer.u32(anchor.name === null ? NONE_STRING : strings.index(anchor.name)); - writer.f64(anchor.x); - writer.f64(anchor.y); - } - for (const guideline of layer.guidelines) { - writer.u32(strings.index(guideline.id)); - writer.u32(guideline.name === null ? NONE_STRING : strings.index(guideline.name)); - writer.u32(guideline.color === null ? NONE_STRING : strings.index(guideline.color)); - writer.u32( - (guideline.x === null ? 0 : 1) | - (guideline.y === null ? 0 : 2) | - (guideline.angle === null ? 0 : 4), - ); - writer.f64(guideline.x ?? 0); - writer.f64(guideline.y ?? 0); - writer.f64(guideline.angle ?? 0); - } - encodeMap(writer, layer.lib, strings, 0, { count: 0 }); - if (writer.offset !== byteLength) - fail("length-mismatch", "internal glyph-layer encoder length mismatch"); - - return [ - bytes, - { - strings: strings.values, - stringCount: strings.count, - contourCount: layer.contours.length, - pointCount, - componentCount: layer.components.length, - anchorCount: layer.anchors.length, - guidelineCount: layer.guidelines.length, - idIndex: strings.index(layer.id), - sourceIdIndex: strings.index(layer.sourceId), - width: layer.width, - height: layer.height, - contoursOffset, - componentsOffset, - anchorsOffset, - guidelinesOffset, - libOffset, - libEnd: byteLength, - }, - ]; -} - -function validateLayerInput(layer: GlyphLayer): void { - validateFinite(layer.width, "width", 0); - if (layer.height !== null) validateFinite(layer.height, "height", 0); - - const identities = new Map>(); - const unique = (kind: string, index: number, id: string): void => { - let values = identities.get(kind); - if (!values) { - values = new Set(); - identities.set(kind, values); - } - if (values.has(id)) fail("duplicate-identity", `duplicate ${kind} identity at index ${index}`); - values.add(id); - }; - - let pointIndex = 0; - for (const [contourIndex, contour] of layer.contours.entries()) { - unique("contour", contourIndex, contour.id); - for (const point of contour.points) { - unique("point", pointIndex, point.id); - validateFinite(point.x, "point coordinate", pointIndex * 2); - validateFinite(point.y, "point coordinate", pointIndex * 2 + 1); - pointIndex += 1; - } - } - for (const [index, component] of layer.components.entries()) { - unique("component", index, component.id); - for (const [valueIndex, value] of transformValues(component.transform).entries()) - validateFinite(value, "component transform", index * 9 + valueIndex); - } - for (const [index, anchor] of layer.anchors.entries()) { - unique("anchor", index, anchor.id); - validateFinite(anchor.x, "anchor coordinate", index * 2); - validateFinite(anchor.y, "anchor coordinate", index * 2 + 1); - } - for (const [index, guideline] of layer.guidelines.entries()) { - unique("guideline", index, guideline.id); - for (const [valueIndex, value] of [guideline.x, guideline.y, guideline.angle].entries()) - if (value !== null) validateFinite(value, "guideline number", index * 3 + valueIndex); - } -} - -function validate(bytes: Uint8Array): Header { - validateFrame(bytes, { - headerLength: HEADER_LENGTH, - kind: LAYER_KIND, - version: LAYER_VERSION, - headerName: "glyph-layer", - versionName: "glyph-layer", - flagsName: "glyph-layer frame", - }); - checkLimit("payload byte length", bytes.byteLength, MAX_LAYER_PAYLOAD_BYTES); - - const view = dataView(bytes); - const declaredLength = view.getUint32(8, true); - if (declaredLength !== bytes.byteLength) - fail( - "length-mismatch", - `glyph-layer payload length mismatch: expected ${declaredLength} bytes, got ${bytes.byteLength}`, - ); - - const stringCount = view.getUint32(12, true); - const stringBytes = view.getUint32(16, true); - const contourCount = view.getUint32(20, true); - const pointCount = view.getUint32(24, true); - const componentCount = view.getUint32(28, true); - const anchorCount = view.getUint32(32, true); - const guidelineCount = view.getUint32(36, true); - const libBytes = view.getUint32(40, true); - const idIndex = view.getUint32(44, true); - const sourceIdIndex = view.getUint32(48, true); - const layerFlags = view.getUint32(52, true); - if ((layerFlags & ~1) !== 0) - fail("unknown-layer-flags", `unknown glyph-layer flags ${hex(layerFlags, 8)}`); - const width = view.getFloat64(56, true); - validateFinite(width, "width", 0); - const rawHeight = view.getFloat64(64, true); - const height = - (layerFlags & 1) === 0 - ? (validateAbsent(view, 64, "height", 0), null) - : (validateFinite(rawHeight, "height", 0), rawHeight); - - checkLimit("string count", stringCount, MAX_LAYER_STRING_COUNT); - checkLimit("string bytes", stringBytes, MAX_LAYER_STRING_BYTES); - checkLimit("contour count", contourCount, MAX_LAYER_CONTOUR_COUNT); - checkLimit("point count", pointCount, MAX_LAYER_POINT_COUNT); - checkLimit("component count", componentCount, MAX_LAYER_ENTITY_COUNT); - checkLimit("anchor count", anchorCount, MAX_LAYER_ENTITY_COUNT); - checkLimit("guideline count", guidelineCount, MAX_LAYER_ENTITY_COUNT); - - const offsetsBytes = checkedMultiply(checkedAdd(stringCount, 1), 4); - const valuesOffset = checkedAdd(HEADER_LENGTH, offsetsBytes); - const stringsEnd = checkedAdd(valuesOffset, stringBytes); - requireRange(bytes, HEADER_LENGTH, checkedAdd(offsetsBytes, stringBytes)); - const strings = validateStrings(bytes, stringCount, stringBytes, HEADER_LENGTH, valuesOffset); - const state = new DecodeState(strings, true); - state.reference(idIndex); - state.reference(sourceIdIndex); - const cursor = new Cursor(bytes, stringsEnd, bytes.byteLength); - const contoursOffset = cursor.offset; - let actualPoints = 0; - for (let contourIndex = 0; contourIndex < contourCount; contourIndex += 1) { - state.uniqueIdentity("contour", contourIndex, state.requiredString(cursor.u32())); - const count = cursor.u32(); - actualPoints = checkedAdd(actualPoints, count); - checkLimit("point count", actualPoints, MAX_LAYER_POINT_COUNT); - const flags = cursor.u32(); - if ((flags & ~1) !== 0) - fail( - "unknown-record-flags", - `unknown contour flags ${hex(flags, 8)} at index ${contourIndex}`, - ); - for (let localIndex = 0; localIndex < count; localIndex += 1) { - const pointIndex = state.pointIndex; - state.uniqueIdentity("point", pointIndex, state.requiredString(cursor.u32())); - pointTypeFromByte(cursor.u8(), pointIndex); - const pointFlags = cursor.u8(); - if ((pointFlags & ~1) !== 0) - fail( - "unknown-record-flags", - `unknown point flags ${hex(pointFlags)} at index ${pointIndex}`, - ); - if (cursor.u16() !== 0) - fail("non-zero-reserved", `non-zero reserved point bytes at index ${pointIndex}`); - validateFinite(cursor.f64(), "point coordinate", pointIndex * 2); - validateFinite(cursor.f64(), "point coordinate", pointIndex * 2 + 1); - state.pointIndex += 1; - } - } - if (actualPoints !== pointCount) - fail( - "point-count-mismatch", - `contours contain ${actualPoints} points, header declares ${pointCount}`, - ); - - const componentsOffset = cursor.offset; - for (let index = 0; index < componentCount; index += 1) { - state.uniqueIdentity("component", index, state.requiredString(cursor.u32())); - state.requiredString(cursor.u32()); - state.requiredString(cursor.u32()); - if (cursor.u32() !== 0) - fail("non-zero-reserved", `non-zero reserved component bytes at index ${index}`); - for (let valueIndex = 0; valueIndex < 9; valueIndex += 1) - validateFinite(cursor.f64(), "component transform", index * 9 + valueIndex); - } - const anchorsOffset = cursor.offset; - for (let index = 0; index < anchorCount; index += 1) { - state.uniqueIdentity("anchor", index, state.requiredString(cursor.u32())); - state.optionalString(cursor.u32()); - validateFinite(cursor.f64(), "anchor coordinate", index * 2); - validateFinite(cursor.f64(), "anchor coordinate", index * 2 + 1); - } - const guidelinesOffset = cursor.offset; - for (let index = 0; index < guidelineCount; index += 1) { - state.uniqueIdentity("guideline", index, state.requiredString(cursor.u32())); - state.optionalString(cursor.u32()); - state.optionalString(cursor.u32()); - const flags = cursor.u32(); - if ((flags & ~7) !== 0) - fail("unknown-record-flags", `unknown guideline flags ${hex(flags, 8)} at index ${index}`); - for (const [valueIndex, bit] of [1, 2, 4].entries()) { - const offset = cursor.offset; - const value = cursor.f64(); - if ((flags & bit) === 0) - validateAbsent(view, offset, "guideline number", index * 3 + valueIndex); - else validateFinite(value, "guideline number", index * 3 + valueIndex); - } - } - const libOffset = cursor.offset; - const libEnd = checkedAdd(libOffset, libBytes); - requireRange(bytes, libOffset, libBytes); - cursor.end = libEnd; - decodeMap(cursor, state, 0); - const consumedLib = cursor.offset - libOffset; - if (consumedLib !== libBytes) - fail( - "lib-length-mismatch", - `layer lib length mismatch: expected ${libBytes} bytes, consumed ${consumedLib}`, - ); - if (libEnd !== bytes.byteLength) - fail( - "length-mismatch", - `glyph-layer payload length mismatch: expected ${libEnd} bytes, got ${bytes.byteLength}`, - ); - if (state.nextString !== stringCount) - fail( - "unreferenced-strings", - `string table has unreferenced entries beginning at index ${state.nextString}`, - ); - - return { - strings, - stringCount, - contourCount, - pointCount, - componentCount, - anchorCount, - guidelineCount, - idIndex, - sourceIdIndex, - width, - height, - contoursOffset, - componentsOffset, - anchorsOffset, - guidelinesOffset, - libOffset, - libEnd, - }; -} - -function transformValues(transform: LayerTransform): readonly number[] { - return [ - transform.translateX, - transform.translateY, - transform.rotation, - transform.scaleX, - transform.scaleY, - transform.skewX, - transform.skewY, - transform.centerX, - transform.centerY, - ]; -} - -function readTransform(view: DataView, offset: number): LayerTransform { - const values = Array.from({ length: 9 }, (_, index) => view.getFloat64(offset + index * 8, true)); - return { - translateX: values[0], - translateY: values[1], - rotation: values[2], - scaleX: values[3], - scaleY: values[4], - skewX: values[5], - skewY: values[6], - centerX: values[7], - centerY: values[8], - }; -} - -function pointTypeByte(type: LayerPointType): number { - switch (type) { - case "on-curve": - return 0; - case "off-curve": - return 1; - case "qcurve": - return 2; - default: - fail("unknown-point-type", `unknown input point type ${String(type)}`); - } -} - -function pointTypeFromByte(value: number, index: number): LayerPointType { - switch (value) { - case 0: - return "on-curve"; - case 1: - return "off-curve"; - case 2: - return "qcurve"; - default: - fail("unknown-point-type", `unknown point type ${hex(value)} at index ${index}`); - } -} - -function optionalString(strings: readonly string[], index: number): string | null { - return index === NONE_STRING ? null : strings[index]; -} - -function validateAbsent(view: DataView, offset: number, field: string, index: number): void { - if (view.getBigUint64(offset, true) !== 0n) - fail( - "non-canonical-absent-number", - `absent ${field} value at index ${index} is not canonical positive zero`, - ); -} diff --git a/packages/glyph-codec/src/outline.test.ts b/packages/glyph-codec/src/outline.test.ts deleted file mode 100644 index 3e268772..00000000 --- a/packages/glyph-codec/src/outline.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { decodeOutline, GlyphCodecError, MAX_COMMAND_COUNT, packOutline } from "./outline"; -import type { GlyphCodecErrorCode, OutlineCommand } from "./types"; - -type GoldenVector = { - readonly name: string; - readonly file: string; - readonly commands: readonly OutlineCommand[]; -}; - -const vectors: readonly GoldenVector[] = [ - { name: "empty", file: "empty.bin", commands: [] }, - { - name: "closed-line", - file: "closed-line.bin", - commands: [{ kind: "move", x: 0, y: 0 }, { kind: "line", x: 100, y: 200 }, { kind: "close" }], - }, - { - name: "open", - file: "open.bin", - commands: [ - { kind: "move", x: -12.5, y: 0.25 }, - { kind: "line", x: 123.75, y: -456.5 }, - ], - }, - { - name: "quadratic-cubic", - file: "quadratic-cubic.bin", - commands: [ - { kind: "move", x: -1.5, y: 2.25 }, - { kind: "quad", cx: 0.5, cy: -0.75, x: 1000.125, y: -2000.5 }, - { - kind: "cubic", - c1x: -3, - c1y: 4.5, - c2x: 5.25, - c2y: -6.75, - x: 1e20, - y: -1e20, - }, - { kind: "close" }, - ], - }, - { - name: "multiple-contours", - file: "multiple-contours.bin", - commands: [ - { kind: "move", x: 0, y: 0 }, - { kind: "line", x: 10, y: 0 }, - { kind: "close" }, - { kind: "move", x: 100.5, y: -100.25 }, - { kind: "line", x: 200, y: 300 }, - ], - }, -]; - -function fixture(file: string): Uint8Array { - return readFileSync(new URL(`../../../fixtures/glyph-codec/outline-v1/${file}`, import.meta.url)); -} - -function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { - return left.length === right.length && left.every((byte, index) => byte === right[index]); -} - -function f32Commands(commands: readonly OutlineCommand[]): OutlineCommand[] { - return commands.map((command) => { - switch (command.kind) { - case "move": - case "line": - return { kind: command.kind, x: Math.fround(command.x), y: Math.fround(command.y) }; - case "quad": - return { - kind: "quad", - cx: Math.fround(command.cx), - cy: Math.fround(command.cy), - x: Math.fround(command.x), - y: Math.fround(command.y), - }; - case "cubic": - return { - kind: "cubic", - c1x: Math.fround(command.c1x), - c1y: Math.fround(command.c1y), - c2x: Math.fround(command.c2x), - c2y: Math.fround(command.c2y), - x: Math.fround(command.x), - y: Math.fround(command.y), - }; - case "close": - return command; - } - }); -} - -function rawOutline(commands: readonly number[], coordinates: readonly number[]): Uint8Array { - const alignedCommands = Math.ceil(commands.length / 4) * 4; - const bytes = new Uint8Array(16 + alignedCommands + coordinates.length * 4); - bytes.set([0x53, 0x48, 0x46, 0x54, 1, 1], 0); - const view = new DataView(bytes.buffer); - view.setUint32(8, commands.length, true); - view.setUint32(12, coordinates.length, true); - bytes.set(commands, 16); - coordinates.forEach((coordinate, index) => - view.setFloat32(16 + alignedCommands + index * 4, coordinate, true), - ); - return bytes; -} - -function codecErrorCode(action: () => unknown): GlyphCodecErrorCode | null { - try { - action(); - return null; - } catch (error) { - if (!(error instanceof GlyphCodecError)) throw error; - return error.code; - } -} - -describe("shift.glyph-outline.v1 shared compatibility", () => { - it.each(vectors)("decodes and canonically re-encodes $name", ({ file, commands }) => { - const bytes = fixture(file); - const outline = decodeOutline(bytes); - - expect([...outline]).toEqual(f32Commands(commands)); - expect([...packOutline(commands).toUint8Array()]).toEqual([...bytes]); - expect([...packOutline([...outline]).toUint8Array()]).toEqual([...bytes]); - }); - - it("covers every command-count alignment remainder", () => { - const remainders = vectors.map(({ file }) => decodeOutline(fixture(file)).commandCount % 4); - - expect(new Set(remainders)).toEqual(new Set([0, 1, 2, 3])); - }); - - it("does not expose mutable validated storage", () => { - const source = fixture("open.bin"); - const outline = decodeOutline(source); - const exported = outline.toUint8Array(); - source[0] = 0; - exported[1] = 0; - - expect([...outline]).toEqual(f32Commands(vectors[2].commands)); - }); -}); - -describe("strict outline decoding", () => { - it("rejects every truncation and trailing bytes", () => { - const bytes = fixture("quadratic-cubic.bin"); - for (let length = 0; length < bytes.length; length += 1) { - expect(codecErrorCode(() => decodeOutline(bytes.slice(0, length)))).not.toBeNull(); - } - - const trailing = new Uint8Array(bytes.length + 1); - trailing.set(bytes); - expect(codecErrorCode(() => decodeOutline(trailing))).toBe("length-mismatch"); - }); - - it("rejects wrong framing and unknown flags", () => { - const mutations: ReadonlyArray = [ - [0, 0, "wrong-magic"], - [4, 2, "unsupported-kind"], - [5, 2, "unsupported-version"], - [6, 1, "unknown-flags"], - ]; - - for (const [offset, value, code] of mutations) { - const bytes = fixture("open.bin"); - bytes[offset] = value; - expect(codecErrorCode(() => decodeOutline(bytes))).toBe(code); - } - }); - - it("rejects unknown commands and illegal command transitions", () => { - const cases: ReadonlyArray = [ - [rawOutline([9], []), "unknown-command"], - [rawOutline([1], [1, 2]), "invalid-command-order"], - [rawOutline([4], []), "invalid-command-order"], - [rawOutline([0, 4], [0, 0]), "invalid-command-order"], - [rawOutline([0, 1, 4, 1], [0, 0, 1, 1, 2, 2]), "invalid-command-order"], - ]; - - for (const [bytes, code] of cases) { - expect(codecErrorCode(() => decodeOutline(bytes))).toBe(code); - } - }); - - it("rejects arity mismatch and non-zero padding", () => { - expect(codecErrorCode(() => decodeOutline(rawOutline([0, 1], [0, 0])))).toBe( - "coordinate-count-mismatch", - ); - - const padding = fixture("open.bin"); - padding[18] = 1; - expect(codecErrorCode(() => decodeOutline(padding))).toBe("non-zero-padding"); - }); - - it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( - "rejects decoded non-finite coordinate %s", - (coordinate) => { - expect(codecErrorCode(() => decodeOutline(rawOutline([0], [coordinate, 0])))).toBe( - "non-finite-coordinate", - ); - }, - ); - - it("rejects illegally ordered encoder input", () => { - const cases: ReadonlyArray = [ - [{ kind: "line", x: 1, y: 2 }], - [{ kind: "close" }], - [{ kind: "move", x: 0, y: 0 }, { kind: "close" }], - ]; - - for (const commands of cases) { - expect(codecErrorCode(() => packOutline(commands))).toBe("invalid-command-order"); - } - }); - - it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( - "rejects non-finite input coordinate %s", - (x) => { - expect(codecErrorCode(() => packOutline([{ kind: "move", x, y: 0 }]))).toBe( - "non-finite-input-coordinate", - ); - }, - ); - - it("rejects finite values that overflow f32", () => { - expect(codecErrorCode(() => packOutline([{ kind: "move", x: Number.MAX_VALUE, y: 0 }]))).toBe( - "coordinate-out-of-f32-range", - ); - }); - - it("applies count limits before reading a declared body", () => { - const bytes = fixture("empty.bin"); - new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( - 8, - MAX_COMMAND_COUNT + 1, - true, - ); - - expect(codecErrorCode(() => decodeOutline(bytes))).toBe("limit-exceeded"); - }); - - it("never accepts a mutated payload with a non-canonical encoding", () => { - const commands = vectors.flatMap((vector) => vector.commands); - const seed = packOutline(commands).toUint8Array(); - let random = 0x7a114e29; - - for (let iteration = 0; iteration < 10_000; iteration += 1) { - random ^= random << 13; - random ^= random >>> 17; - random ^= random << 5; - const candidate = seed.slice(); - const offset = (random >>> 0) % candidate.length; - candidate[offset] ^= ((random >>> 24) | 1) & 0xff; - const mutated = - iteration % 7 === 0 - ? candidate.slice(0, (random >>> 0) % candidate.length) - : iteration % 11 === 0 - ? Uint8Array.from([...candidate, (random >>> 16) & 0xff]) - : candidate; - - try { - const outline = decodeOutline(mutated); - const canonical = packOutline([...outline]).toUint8Array(); - if (!bytesEqual(canonical, mutated)) { - throw new Error("decoder accepted a non-canonical outline encoding"); - } - } catch (error) { - if (!(error instanceof GlyphCodecError)) throw error; - } - } - }); -}); diff --git a/packages/glyph-codec/src/outline.ts b/packages/glyph-codec/src/outline.ts deleted file mode 100644 index ccb5a702..00000000 --- a/packages/glyph-codec/src/outline.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { fail } from "./error"; -import { validateFrame, writeFrame } from "./frame"; -import type { OutlineCommand } from "./types"; - -export { GlyphCodecError } from "./error"; - -const OUTLINE_KIND = 0x01; -const OUTLINE_VERSION = 0x01; -const HEADER_LENGTH = 16; - -export const MAX_COMMAND_COUNT = 1_000_000; -export const MAX_COORDINATE_COUNT = 6_000_000; -export const MAX_PAYLOAD_BYTES = 32 * 1024 * 1024; - -type Header = { - readonly commandCount: number; - readonly coordinateCount: number; - readonly coordinateOffset: number; -}; - -type CommandState = "between" | "active-empty" | "active-drawing"; - -/** - * Opaque, immutable, validated `shift.glyph-outline.v1` bytes. - * - * The constructor is intentionally private. Decode copies caller-owned bytes so - * later mutation cannot invalidate the checked command/coordinate view; packed - * bytes are allocated and retained directly. `toUint8Array()` likewise returns - * a copy rather than exposing mutable internal storage. - */ -export class PackedGlyphOutline implements Iterable { - readonly #bytes: Uint8Array; - readonly #header: Header; - - private constructor(bytes: Uint8Array, header: Header) { - this.#bytes = bytes; - this.#header = header; - } - - static decode(data: Uint8Array): PackedGlyphOutline { - checkLimit("payload byte length", data.byteLength, MAX_PAYLOAD_BYTES); - const bytes = Uint8Array.from(data); - const header = validate(bytes); - return new PackedGlyphOutline(bytes, header); - } - - static pack(commands: readonly OutlineCommand[]): PackedGlyphOutline { - checkLimit("command count", commands.length, MAX_COMMAND_COUNT); - - let state: CommandState = "between"; - let coordinateCount = 0; - for (const [index, command] of commands.entries()) { - state = nextState(state, commandByte(command), index); - coordinateCount = checkedAdd(coordinateCount, commandArity(command)); - checkLimit("coordinate count", coordinateCount, MAX_COORDINATE_COUNT); - } - - const alignedCommands = align4(commands.length); - const byteLength = checkedAdd( - checkedAdd(HEADER_LENGTH, alignedCommands), - checkedMultiply(coordinateCount, 4), - ); - checkLimit("payload byte length", byteLength, MAX_PAYLOAD_BYTES); - - const bytes = new Uint8Array(byteLength); - writeFrame(bytes, OUTLINE_KIND, OUTLINE_VERSION); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - view.setUint32(8, commands.length, true); - view.setUint32(12, coordinateCount, true); - - let commandOffset = HEADER_LENGTH; - let coordinateIndex = 0; - const coordinateOffset = HEADER_LENGTH + alignedCommands; - for (const command of commands) { - bytes[commandOffset] = commandByte(command); - commandOffset += 1; - for (const coordinate of commandCoordinates(command)) { - view.setFloat32( - coordinateOffset + coordinateIndex * 4, - convertCoordinate(coordinate, coordinateIndex), - true, - ); - coordinateIndex += 1; - } - } - - return new PackedGlyphOutline(bytes, { - commandCount: commands.length, - coordinateCount, - coordinateOffset, - }); - } - - get byteLength(): number { - return this.#bytes.byteLength; - } - - get commandCount(): number { - return this.#header.commandCount; - } - - get coordinateCount(): number { - return this.#header.coordinateCount; - } - - /** Returns a mutable copy suitable for transport. */ - toUint8Array(): Uint8Array { - return this.#bytes.slice(); - } - - /** Iterates validated commands without allocating a point-object graph. */ - *commands(): IterableIterator { - const view = new DataView(this.#bytes.buffer, this.#bytes.byteOffset, this.#bytes.byteLength); - let coordinateOffset = this.#header.coordinateOffset; - const next = (): number => { - const value = view.getFloat32(coordinateOffset, true); - coordinateOffset += 4; - return value; - }; - - for (let index = 0; index < this.#header.commandCount; index += 1) { - switch (this.#bytes[HEADER_LENGTH + index]) { - case 0: - yield { kind: "move", x: next(), y: next() }; - break; - case 1: - yield { kind: "line", x: next(), y: next() }; - break; - case 2: - yield { kind: "quad", cx: next(), cy: next(), x: next(), y: next() }; - break; - case 3: - yield { - kind: "cubic", - c1x: next(), - c1y: next(), - c2x: next(), - c2y: next(), - x: next(), - y: next(), - }; - break; - case 4: - yield { kind: "close" }; - break; - default: - throw new Error("PackedGlyphOutline command invariant violated"); - } - } - } - - /** Iterates all finite f32 values in command-stream order. */ - *coordinates(): IterableIterator { - const view = new DataView(this.#bytes.buffer, this.#bytes.byteOffset, this.#bytes.byteLength); - for (let index = 0; index < this.#header.coordinateCount; index += 1) { - yield view.getFloat32(this.#header.coordinateOffset + index * 4, true); - } - } - - [Symbol.iterator](): IterableIterator { - return this.commands(); - } -} - -/** Canonically encodes drawing commands into an opaque outline payload. */ -export function packOutline(commands: readonly OutlineCommand[]): PackedGlyphOutline { - return PackedGlyphOutline.pack(commands); -} - -/** Strictly validates and copies an outline payload. */ -export function decodeOutline(data: Uint8Array): PackedGlyphOutline { - return PackedGlyphOutline.decode(data); -} - -function validate(bytes: Uint8Array): Header { - validateFrame(bytes, { - headerLength: HEADER_LENGTH, - kind: OUTLINE_KIND, - version: OUTLINE_VERSION, - headerName: "outline", - versionName: "glyph-outline", - flagsName: "glyph-outline", - }); - checkLimit("payload byte length", bytes.byteLength, MAX_PAYLOAD_BYTES); - - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - - const commandCount = view.getUint32(8, true); - const coordinateCount = view.getUint32(12, true); - checkLimit("command count", commandCount, MAX_COMMAND_COUNT); - checkLimit("coordinate count", coordinateCount, MAX_COORDINATE_COUNT); - - const alignedCommands = align4(commandCount); - const expectedLength = checkedAdd( - checkedAdd(HEADER_LENGTH, alignedCommands), - checkedMultiply(coordinateCount, 4), - ); - checkLimit("payload byte length", expectedLength, MAX_PAYLOAD_BYTES); - if (bytes.byteLength !== expectedLength) { - fail( - "length-mismatch", - `outline payload length mismatch: expected ${expectedLength} bytes, got ${bytes.byteLength}`, - ); - } - - let state: CommandState = "between"; - let expectedCoordinateCount = 0; - for (let index = 0; index < commandCount; index += 1) { - const command = bytes[HEADER_LENGTH + index]; - const arity = byteArity(command, index); - state = nextState(state, command, index); - expectedCoordinateCount = checkedAdd(expectedCoordinateCount, arity); - } - if (expectedCoordinateCount !== coordinateCount) { - fail( - "coordinate-count-mismatch", - `outline command arity requires ${expectedCoordinateCount} coordinates, header declares ${coordinateCount}`, - ); - } - - const commandsEnd = HEADER_LENGTH + commandCount; - const coordinateOffset = HEADER_LENGTH + alignedCommands; - for (let offset = commandsEnd; offset < coordinateOffset; offset += 1) { - if (bytes[offset] !== 0) { - fail("non-zero-padding", `non-zero outline alignment padding at byte ${offset}`); - } - } - - for (let index = 0; index < coordinateCount; index += 1) { - const coordinate = view.getFloat32(coordinateOffset + index * 4, true); - if (!Number.isFinite(coordinate)) { - fail("non-finite-coordinate", `non-finite outline coordinate at index ${index}`); - } - } - - return { commandCount, coordinateCount, coordinateOffset }; -} - -function nextState(state: CommandState, command: number, index: number): CommandState { - switch (command) { - case 0: - return "active-empty"; - case 1: - case 2: - case 3: - if (state === "between") { - fail( - "invalid-command-order", - `invalid outline command ordering at index ${index}: drawing command requires an active contour`, - ); - } - return "active-drawing"; - case 4: - if (state === "between") { - fail( - "invalid-command-order", - `invalid outline command ordering at index ${index}: close requires an active contour`, - ); - } - if (state === "active-empty") { - fail( - "invalid-command-order", - `invalid outline command ordering at index ${index}: close requires at least one drawing segment`, - ); - } - return "between"; - default: - fail("unknown-command", `unknown outline command ${hex(command)} at index ${index}`); - } -} - -function commandByte(command: OutlineCommand): number { - switch (command.kind) { - case "move": - return 0; - case "line": - return 1; - case "quad": - return 2; - case "cubic": - return 3; - case "close": - return 4; - } -} - -function commandArity(command: OutlineCommand): number { - switch (command.kind) { - case "move": - case "line": - return 2; - case "quad": - return 4; - case "cubic": - return 6; - case "close": - return 0; - } -} - -function byteArity(command: number, index: number): number { - switch (command) { - case 0: - case 1: - return 2; - case 2: - return 4; - case 3: - return 6; - case 4: - return 0; - default: - fail("unknown-command", `unknown outline command ${hex(command)} at index ${index}`); - } -} - -function commandCoordinates(command: OutlineCommand): readonly number[] { - switch (command.kind) { - case "move": - case "line": - return [command.x, command.y]; - case "quad": - return [command.cx, command.cy, command.x, command.y]; - case "cubic": - return [command.c1x, command.c1y, command.c2x, command.c2y, command.x, command.y]; - case "close": - return []; - } -} - -function convertCoordinate(value: number, index: number): number { - if (!Number.isFinite(value)) { - fail("non-finite-input-coordinate", `non-finite input coordinate at index ${index}`); - } - - const converted = Math.fround(value); - if (!Number.isFinite(converted)) { - fail( - "coordinate-out-of-f32-range", - `input coordinate at index ${index} is outside finite f32 range`, - ); - } - return converted; -} - -function align4(value: number): number { - return checkedMultiply(Math.ceil(value / 4), 4); -} - -function checkedAdd(left: number, right: number): number { - const value = left + right; - if (!Number.isSafeInteger(value)) { - fail("length-overflow", "outline payload length arithmetic overflowed"); - } - return value; -} - -function checkedMultiply(left: number, right: number): number { - const value = left * right; - if (!Number.isSafeInteger(value)) { - fail("length-overflow", "outline payload length arithmetic overflowed"); - } - return value; -} - -function checkLimit(field: string, actual: number, limit: number): void { - if (actual > limit) { - fail("limit-exceeded", `outline ${field} exceeds implementation limit ${limit}: got ${actual}`); - } -} - -function hex(value: number, width = 2): string { - return `0x${value.toString(16).padStart(width, "0")}`; -} diff --git a/packages/glyph-codec/src/types.ts b/packages/glyph-codec/src/types.ts deleted file mode 100644 index 6eeb4a9c..00000000 --- a/packages/glyph-codec/src/types.ts +++ /dev/null @@ -1,133 +0,0 @@ -export type OutlineCommand = - | { readonly kind: "move"; readonly x: number; readonly y: number } - | { readonly kind: "line"; readonly x: number; readonly y: number } - | { - readonly kind: "quad"; - readonly cx: number; - readonly cy: number; - readonly x: number; - readonly y: number; - } - | { - readonly kind: "cubic"; - readonly c1x: number; - readonly c1y: number; - readonly c2x: number; - readonly c2y: number; - readonly x: number; - readonly y: number; - } - | { readonly kind: "close" }; - -export type LayerPointType = "on-curve" | "off-curve" | "qcurve"; - -export type LayerPoint = { - readonly id: string; - readonly type: LayerPointType; - readonly smooth: boolean; - readonly x: number; - readonly y: number; -}; - -export type LayerContour = { - readonly id: string; - readonly closed: boolean; - readonly points: readonly LayerPoint[]; -}; - -export type LayerTransform = { - readonly translateX: number; - readonly translateY: number; - readonly rotation: number; - readonly scaleX: number; - readonly scaleY: number; - readonly skewX: number; - readonly skewY: number; - readonly centerX: number; - readonly centerY: number; -}; - -export type LayerComponent = { - readonly id: string; - readonly baseGlyphId: string; - readonly baseGlyphName: string; - readonly transform: LayerTransform; -}; - -export type LayerAnchor = { - readonly id: string; - readonly name: string | null; - readonly x: number; - readonly y: number; -}; - -export type LayerGuideline = { - readonly id: string; - readonly x: number | null; - readonly y: number | null; - readonly angle: number | null; - readonly name: string | null; - readonly color: string | null; -}; - -export type LayerLibValue = - | { readonly kind: "string"; readonly value: string } - | { readonly kind: "integer"; readonly value: bigint } - | { readonly kind: "unsigned-integer"; readonly value: bigint } - | { readonly kind: "float"; readonly value: number } - | { readonly kind: "boolean"; readonly value: boolean } - | { readonly kind: "array"; readonly value: readonly LayerLibValue[] } - | { readonly kind: "dict"; readonly value: ReadonlyMap } - | { readonly kind: "data"; readonly value: Uint8Array } - | { readonly kind: "date"; readonly value: string } - | { readonly kind: "uid"; readonly value: bigint }; - -export type GlyphLayer = { - readonly id: string; - readonly sourceId: string; - readonly width: number; - readonly height: number | null; - readonly contours: readonly LayerContour[]; - readonly components: readonly LayerComponent[]; - readonly anchors: readonly LayerAnchor[]; - readonly guidelines: readonly LayerGuideline[]; - readonly lib: ReadonlyMap; -}; - -export type GlyphCodecErrorCode = - | "header-truncated" - | "wrong-magic" - | "unsupported-kind" - | "unsupported-version" - | "unknown-flags" - | "unknown-layer-flags" - | "unknown-record-flags" - | "non-zero-reserved" - | "unknown-command" - | "unknown-point-type" - | "unknown-lib-tag" - | "invalid-boolean" - | "invalid-utf8" - | "invalid-string-offsets" - | "duplicate-string" - | "string-reference-out-of-range" - | "non-canonical-string-reference" - | "unreferenced-strings" - | "duplicate-identity" - | "non-canonical-map-order" - | "invalid-command-order" - | "coordinate-count-mismatch" - | "point-count-mismatch" - | "lib-length-mismatch" - | "non-zero-padding" - | "non-finite-coordinate" - | "non-finite-input-coordinate" - | "coordinate-out-of-f32-range" - | "non-finite-number" - | "non-canonical-absent-number" - | "integer-out-of-range" - | "nesting-limit-exceeded" - | "truncated" - | "length-overflow" - | "length-mismatch" - | "limit-exceeded"; diff --git a/packages/glyph-codec/tsconfig.json b/packages/glyph-codec/tsconfig.json deleted file mode 100644 index f53b6d51..00000000 --- a/packages/glyph-codec/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig/library.json", - "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist", - "types": ["node"] - }, - "include": ["src"] -} diff --git a/packages/glyph-codec/vitest.config.ts b/packages/glyph-codec/vitest.config.ts deleted file mode 100644 index 20a45a26..00000000 --- a/packages/glyph-codec/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - include: ["src/**/*.test.ts"], - passWithNoTests: false, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1573ba18..4cc05c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,18 +242,6 @@ importers: specifier: ^4.0.17 version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) - packages/glyph-codec: - devDependencies: - '@types/node': - specifier: ^25.3.0 - version: 25.3.0 - typescript: - specifier: ^5.5.4 - version: 5.9.3 - vitest: - specifier: ^4.0.17 - version: 4.0.17(@types/node@25.3.0)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.21.0)(yaml@2.8.2) - packages/glyph-info: dependencies: minisearch: @@ -2182,6 +2170,7 @@ packages: '@xmldom/xmldom@0.8.10': resolution: {integrity: sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -4209,6 +4198,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 From d1715f5c8b44ee4103486d905f70bd6b2cbfdb61 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 31 Jul 2026 15:19:15 -0400 Subject: [PATCH 25/29] Modularize glyph layer storage --- crates/shift-store/README.md | 22 +- crates/shift-store/src/change_set.rs | 15 +- crates/shift-store/src/font_state.rs | 2 +- .../{stream_writer.rs => import_writer.rs} | 7 +- crates/shift-store/src/layer/directory.rs | 191 +++ .../src/{stored_layer.rs => layer/format.rs} | 192 +-- crates/shift-store/src/layer/load.rs | 356 ++++ crates/shift-store/src/layer/mod.rs | 36 + crates/shift-store/src/layer/payload.rs | 216 +++ crates/shift-store/src/layer/references.rs | 154 ++ crates/shift-store/src/layer/tests.rs | 669 ++++++++ crates/shift-store/src/layer/write.rs | 178 ++ crates/shift-store/src/lib.rs | 9 +- crates/shift-store/src/packed_layer.rs | 1523 ----------------- crates/shift-store/src/types.rs | 37 +- 15 files changed, 1834 insertions(+), 1773 deletions(-) rename crates/shift-store/src/{stream_writer.rs => import_writer.rs} (96%) create mode 100644 crates/shift-store/src/layer/directory.rs rename crates/shift-store/src/{stored_layer.rs => layer/format.rs} (59%) create mode 100644 crates/shift-store/src/layer/load.rs create mode 100644 crates/shift-store/src/layer/mod.rs create mode 100644 crates/shift-store/src/layer/payload.rs create mode 100644 crates/shift-store/src/layer/references.rs create mode 100644 crates/shift-store/src/layer/tests.rs create mode 100644 crates/shift-store/src/layer/write.rs delete mode 100644 crates/shift-store/src/packed_layer.rs diff --git a/crates/shift-store/README.md b/crates/shift-store/README.md index c6cdbbef..584b94f8 100644 --- a/crates/shift-store/README.md +++ b/crates/shift-store/README.md @@ -49,13 +49,19 @@ Shift has not shipped this working-store schema. Schema changes therefore update ```text src/ - connection.rs # normal WAL opening plus disposable import/finalization posture - schema.rs # pre-release version-1 baseline - font_state.rs # eager metadata/directory and explicit full materialization - packed_layer.rs # bounded BLOB fetch/replace and reference-index checks - stored_layer.rs # strict MessagePack plus independent Zstd/BLAKE3 storage representation - stream_writer.rs # independently pipelined Rayon encode/compress plus single-transaction SQLite sink - write_mode.rs # shared insert/upsert policy for canonical write paths - change_set.rs # transactional workspace changes and one final touched-layer write + connection.rs # normal WAL opening plus disposable import/finalization posture + schema.rs # pre-release version-1 baseline + font_state.rs # eager metadata/directory and explicit full materialization + import_writer.rs # pipelined Rayon encode/compress plus one SQLite transaction owner + layer/ + format.rs # strict MessagePack encoding and domain validation + payload.rs # independent Zstd/BLAKE3 storage representation + directory.rs # relational metadata and row cross-validation + load.rs # bounded single and batch BLOB reads + write.rs # transactional payload and reference-index writes + references.rs # component dependency queries and index validation + tests.rs # integrated SQLite layer behavior + write_mode.rs # shared insert/upsert policy for canonical write paths + change_set.rs # transactional workspace changes and one final touched-layer write workspace_state.rs ``` diff --git a/crates/shift-store/src/change_set.rs b/crates/shift-store/src/change_set.rs index 83d047e9..360288cf 100644 --- a/crates/shift-store/src/change_set.rs +++ b/crates/shift-store/src/change_set.rs @@ -5,7 +5,7 @@ use shift_font as font; use crate::{ ShiftStore, StoreError, - packed_layer::{create_empty_layer_in_tx, rewrite_layer_in_tx, write_layer_in_tx}, + layer::{create_empty_layer_in_tx, rewrite_layer_in_tx, write_layer_in_tx}, source::SourceKind, workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, @@ -491,13 +491,12 @@ fn update_packed_layer( layer_id: &font::LayerId, update: impl FnOnce(&mut font::GlyphLayer) -> Result<(), StoreError>, ) -> Result<(), StoreError> { - let mut layer = - crate::packed_layer::load_glyph_layer_from_conn(tx, layer_id)?.ok_or_else(|| { - StoreError::MissingEntity { - kind: "glyph layer", - id: layer_id.to_string(), - } - })?; + let mut layer = crate::layer::load_glyph_layer_from_conn(tx, layer_id)?.ok_or_else(|| { + StoreError::MissingEntity { + kind: "glyph layer", + id: layer_id.to_string(), + } + })?; update(&mut layer)?; rewrite_layer_in_tx(tx, &layer) } diff --git a/crates/shift-store/src/font_state.rs b/crates/shift-store/src/font_state.rs index 8f412f1a..e9aed2bf 100644 --- a/crates/shift-store/src/font_state.rs +++ b/crates/shift-store/src/font_state.rs @@ -387,7 +387,7 @@ fn load_glyphs( .iter() .map(|(layer_id, _, _, _, _)| layer_id.clone()) .collect::>(); - for layer in crate::packed_layer::load_glyph_layers_from_conn(conn, &layer_ids)? { + for layer in crate::layer::load_glyph_layers_from_conn(conn, &layer_ids)? { loaded_layers.insert(layer.id(), layer); } } diff --git a/crates/shift-store/src/stream_writer.rs b/crates/shift-store/src/import_writer.rs similarity index 96% rename from crates/shift-store/src/stream_writer.rs rename to crates/shift-store/src/import_writer.rs index cbd56aa4..9a6e35d3 100644 --- a/crates/shift-store/src/stream_writer.rs +++ b/crates/shift-store/src/import_writer.rs @@ -7,10 +7,9 @@ use shift_font as font; use crate::{ GlyphWriteBatch, LayerBatchTiming, ShiftStore, StoreError, change_set::{replace_font_header_in_tx, write_glyph_directory_in_tx}, - packed_layer::store_stored_layer_in_tx, + layer::{StoredLayerPayload, compress_layer, encode_layer, store_stored_layer_in_tx}, schema::{defer_import_indexes, restore_import_indexes}, - stored_layer::{compress_glyph_layer, encode_layer}, - types::{EncodedGlyphLayers, PackedGlyph, StoredLayerPayload}, + types::{EncodedGlyphLayers, PackedGlyph}, write_mode::WriteMode, }; @@ -155,7 +154,7 @@ fn compress_glyph_layers( layers .into_iter() .map(|(layer_id, encoded)| { - compress_glyph_layer(&encoded).map(|stored| (layer_id, stored)) + compress_layer(&encoded).map(|stored| (layer_id, stored)) }) .collect::, _>>() }) diff --git a/crates/shift-store/src/layer/directory.rs b/crates/shift-store/src/layer/directory.rs new file mode 100644 index 00000000..f7501728 --- /dev/null +++ b/crates/shift-store/src/layer/directory.rs @@ -0,0 +1,191 @@ +use shift_font as font; + +use super::{ + GLYPH_LAYER_FORMAT, MAX_LAYER_PAYLOAD_BYTES, + payload::{LayerPayloadCompression, StoredLayerPayload}, +}; +use crate::{ShiftStore, StoreError}; + +const MAX_LAYER_PAYLOAD_BYTES_SQL: i64 = MAX_LAYER_PAYLOAD_BYTES as i64; + +/// Relational facts needed to locate a layer without reading its payload. +#[derive(Clone, Debug, PartialEq)] +pub struct GlyphLayerDirectoryEntry { + pub layer_id: font::LayerId, + pub glyph_id: font::GlyphId, + pub source_id: font::SourceId, + pub name: Option, + pub width: f64, + pub height: Option, + pub stored_byte_length: u64, + pub decoded_byte_length: u64, +} + +impl ShiftStore { + pub fn list_glyph_layer_directory(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT l.id, l.glyph_id, l.source_id, g.name, l.width, l.height, + p.stored_byte_length, p.decoded_byte_length + FROM glyph_layers AS l + JOIN glyphs AS g ON g.id = l.glyph_id + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + ORDER BY l.glyph_id, l.source_id, l.id + ", + )?; + let rows = stmt.query_map([], map_directory_row)?; + rows.collect::, _>>().map_err(Into::into) + } +} + +pub(super) struct DirectoryFacts { + pub(super) layer_id: String, + pub(super) source_id: String, + pub(super) width: f64, + pub(super) height: Option, + inner_format: String, + compression: String, + stored_byte_length: i64, + decoded_byte_length: i64, + decoded_blake3: Vec, + pub(super) actual_stored_byte_length: i64, +} + +impl DirectoryFacts { + pub(super) fn validate(&self) -> Result { + if self.inner_format != GLYPH_LAYER_FORMAT { + return Err(StoreError::UnsupportedLayerFormat( + self.inner_format.clone(), + )); + } + LayerPayloadCompression::try_from(self.compression.as_str())?; + if self.stored_byte_length != self.actual_stored_byte_length { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "declared stored byte length {} != actual {}", + self.stored_byte_length, self.actual_stored_byte_length + ), + }); + } + for byte_length in [self.stored_byte_length, self.decoded_byte_length] { + if !(0..=MAX_LAYER_PAYLOAD_BYTES_SQL).contains(&byte_length) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(byte_length).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + } + if self.stored_byte_length > self.decoded_byte_length { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "stored byte length {} exceeds decoded byte length {}", + self.stored_byte_length, self.decoded_byte_length + ), + }); + } + if self.decoded_blake3.len() != 32 { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: format!( + "decoded BLAKE3 has {} bytes instead of 32", + self.decoded_blake3.len() + ), + }); + } + Ok(self.decoded_byte_length as u64) + } + + pub(super) fn stored_layer(&self, payload: Vec) -> Result { + let decoded_blake3: [u8; 32] = self.decoded_blake3.as_slice().try_into().map_err(|_| { + StoreError::LayerDirectoryMismatch { + layer_id: self.layer_id.clone(), + detail: "decoded BLAKE3 is not 32 bytes".to_string(), + } + })?; + Ok(StoredLayerPayload { + compression: self.compression.as_str().try_into()?, + stored_byte_length: self.stored_byte_length as u64, + decoded_byte_length: self.decoded_byte_length as u64, + decoded_blake3, + bytes: payload, + }) + } +} + +pub(super) fn map_directory_facts_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(DirectoryFacts { + layer_id: row.get(0)?, + source_id: row.get(1)?, + width: row.get(2)?, + height: row.get(3)?, + inner_format: row.get(4)?, + compression: row.get(5)?, + stored_byte_length: row.get(6)?, + decoded_byte_length: row.get(7)?, + decoded_blake3: row.get(8)?, + actual_stored_byte_length: row.get(9)?, + }) +} + +pub(super) fn validate_directory_facts( + expected_id: &font::LayerId, + expected_source_id: &str, + expected_width: f64, + expected_height: Option, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let detail = if layer.id() != *expected_id { + Some(format!("payload id is {}", layer.id())) + } else if layer.source_id().as_str() != expected_source_id { + Some(format!("payload source id is {}", layer.source_id())) + } else if layer.width().to_bits() != expected_width.to_bits() { + Some("payload width differs from directory width".to_string()) + } else if !same_optional_f64(layer.height(), expected_height) { + Some("payload height differs from directory height".to_string()) + } else { + None + }; + if let Some(detail) = detail { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: expected_id.to_string(), + detail, + }); + } + Ok(()) +} + +fn map_directory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let stored_byte_length = directory_byte_length(row, 6)?; + let decoded_byte_length = directory_byte_length(row, 7)?; + Ok(GlyphLayerDirectoryEntry { + layer_id: font::LayerId::from_raw(row.get::<_, String>(0)?), + glyph_id: font::GlyphId::from_raw(row.get::<_, String>(1)?), + source_id: font::SourceId::from_raw(row.get::<_, String>(2)?), + name: row.get::<_, Option>(3)?.map(font::GlyphName::from), + width: row.get(4)?, + height: row.get(5)?, + stored_byte_length, + decoded_byte_length, + }) +} + +fn directory_byte_length(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result { + let byte_length = row.get::<_, i64>(index)?; + u64::try_from(byte_length).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Integer, + Box::new(error), + ) + }) +} + +fn same_optional_f64(left: Option, right: Option) -> bool { + match (left, right) { + (Some(left), Some(right)) => left.to_bits() == right.to_bits(), + (None, None) => true, + _ => false, + } +} diff --git a/crates/shift-store/src/stored_layer.rs b/crates/shift-store/src/layer/format.rs similarity index 59% rename from crates/shift-store/src/stored_layer.rs rename to crates/shift-store/src/layer/format.rs index 36db44b3..7772db0a 100644 --- a/crates/shift-store/src/stored_layer.rs +++ b/crates/shift-store/src/layer/format.rs @@ -1,22 +1,14 @@ -use std::{cell::RefCell, collections::HashSet, io::Cursor}; +use std::{collections::HashSet, io::Cursor}; use serde::Deserialize; use shift_font::{self as font, GlyphEntityId, LibValue}; -use crate::{ - StoreError, - types::{LayerPayloadCompression, StoredLayerPayload}, -}; +use super::check_layer_length; +use crate::StoreError; -pub(crate) const MAX_LAYER_PAYLOAD_BYTES: usize = 256 * 1024 * 1024; +pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; const MAX_NESTING_DEPTH: usize = 64; const MAX_LIB_VALUES: usize = 1_000_000; -const ZSTD_COMPRESSION_LEVEL: i32 = 1; - -thread_local! { - static ZSTD_COMPRESSOR: RefCell>> = const { RefCell::new(None) }; - static ZSTD_DECOMPRESSOR: RefCell>> = const { RefCell::new(None) }; -} pub(crate) fn encode_layer(layer: &font::GlyphLayer) -> Result, StoreError> { validate_layer(layer).map_err(StoreError::InvalidLayerPayload)?; @@ -156,129 +148,6 @@ fn validate_finite(value: f64, field: &str) -> Result<(), String> { Err(format!("non-finite {field}")) } -pub(crate) fn compress_glyph_layer(decoded: &[u8]) -> Result { - check_layer_length(decoded.len() as u64)?; - let compressed = ZSTD_COMPRESSOR.with(|slot| { - let mut slot = slot.borrow_mut(); - if slot.is_none() { - *slot = Some( - zstd::bulk::Compressor::new(ZSTD_COMPRESSION_LEVEL) - .map_err(|error| StoreError::LayerCompression(error.to_string()))?, - ); - } - slot.as_mut() - .expect("compressor was initialized") - .compress(decoded) - .map_err(|error| StoreError::LayerCompression(error.to_string())) - })?; - let decoded_byte_length = decoded.len() as u64; - let decoded_blake3 = *blake3::hash(decoded).as_bytes(); - - if compressed.len() < decoded.len() { - return Ok(StoredLayerPayload { - compression: LayerPayloadCompression::ZstandardV1, - stored_byte_length: compressed.len() as u64, - decoded_byte_length, - decoded_blake3, - bytes: compressed, - }); - } - - Ok(StoredLayerPayload { - compression: LayerPayloadCompression::None, - stored_byte_length: decoded_byte_length, - decoded_byte_length, - decoded_blake3, - bytes: decoded.to_vec(), - }) -} - -pub(crate) fn decompress_glyph_layer( - layer_id: &str, - stored: StoredLayerPayload, -) -> Result, StoreError> { - check_layer_length(stored.stored_byte_length)?; - check_layer_length(stored.decoded_byte_length)?; - if stored.bytes.len() as u64 != stored.stored_byte_length { - return Err(directory_mismatch( - layer_id, - format!( - "stored byte length {} != actual {}", - stored.stored_byte_length, - stored.bytes.len() - ), - )); - } - - let decoded = match stored.compression { - LayerPayloadCompression::None => stored.bytes, - LayerPayloadCompression::ZstandardV1 => { - let frame_length = - zstd::zstd_safe::find_frame_compressed_size(&stored.bytes).map_err(|code| { - StoreError::LayerDecompression(zstd::zstd_safe::get_error_name(code).to_owned()) - })?; - if frame_length != stored.bytes.len() { - return Err(directory_mismatch( - layer_id, - format!( - "zstd frame has {frame_length} bytes, stored payload has {}", - stored.bytes.len() - ), - )); - } - let decoded_capacity = usize::try_from(stored.decoded_byte_length).map_err(|_| { - StoreError::LayerPayloadTooLarge { - bytes: stored.decoded_byte_length, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - } - })?; - ZSTD_DECOMPRESSOR.with(|slot| { - let mut slot = slot.borrow_mut(); - if slot.is_none() { - *slot = Some( - zstd::bulk::Decompressor::new() - .map_err(|error| StoreError::LayerDecompression(error.to_string()))?, - ); - } - slot.as_mut() - .expect("decompressor was initialized") - .decompress(&stored.bytes, decoded_capacity) - .map_err(|error| StoreError::LayerDecompression(error.to_string())) - })? - } - }; - - if decoded.len() as u64 != stored.decoded_byte_length { - return Err(directory_mismatch( - layer_id, - format!( - "decoded byte length {} != actual {}", - stored.decoded_byte_length, - decoded.len() - ), - )); - } - if blake3::hash(&decoded).as_bytes() != &stored.decoded_blake3 { - return Err(directory_mismatch(layer_id, "decoded BLAKE3 mismatch")); - } - Ok(decoded) -} - -fn check_layer_length(bytes: u64) -> Result<(), StoreError> { - let limit = MAX_LAYER_PAYLOAD_BYTES as u64; - if bytes > limit { - return Err(StoreError::LayerPayloadTooLarge { bytes, limit }); - } - Ok(()) -} - -fn directory_mismatch(layer_id: &str, detail: impl Into) -> StoreError { - StoreError::LayerDirectoryMismatch { - layer_id: layer_id.to_owned(), - detail: detail.into(), - } -} - #[cfg(test)] mod tests { use std::collections::BTreeMap; @@ -456,57 +325,4 @@ mod tests { Err(StoreError::InvalidLayerPayload(_)) )); } - - #[test] - fn incompressible_bytes_fall_back_to_none() { - let mut value = 0x1234_5678_u32; - let decoded = (0..64) - .map(|_| { - value = value.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); - (value >> 24) as u8 - }) - .collect::>(); - - let stored = compress_glyph_layer(&decoded).unwrap(); - - assert_eq!(stored.compression, LayerPayloadCompression::None); - assert_eq!( - decompress_glyph_layer("layer_test", stored).unwrap(), - decoded - ); - } - - #[test] - fn compressed_bytes_reject_truncation_trailing_data_and_wrong_hash() { - let decoded = vec![0; 4096]; - let mut truncated = compress_glyph_layer(&decoded).unwrap(); - assert_eq!(truncated.compression, LayerPayloadCompression::ZstandardV1); - truncated.bytes.pop(); - truncated.stored_byte_length -= 1; - assert!(decompress_glyph_layer("layer_test", truncated).is_err()); - - let mut trailing = compress_glyph_layer(&decoded).unwrap(); - trailing.bytes.push(0); - trailing.stored_byte_length += 1; - assert!(decompress_glyph_layer("layer_test", trailing).is_err()); - - let mut wrong_hash = compress_glyph_layer(&decoded).unwrap(); - wrong_hash.decoded_blake3[0] ^= 1; - assert!(decompress_glyph_layer("layer_test", wrong_hash).is_err()); - } - - #[test] - fn unsupported_compression_and_oversized_decode_are_rejected() { - assert!(matches!( - LayerPayloadCompression::try_from("zstd.v2"), - Err(StoreError::UnsupportedLayerCompression(_)) - )); - - let mut stored = compress_glyph_layer(&vec![0; 4096]).unwrap(); - stored.decoded_byte_length = MAX_LAYER_PAYLOAD_BYTES as u64 + 1; - assert!(matches!( - decompress_glyph_layer("layer_test", stored), - Err(StoreError::LayerPayloadTooLarge { .. }) - )); - } } diff --git a/crates/shift-store/src/layer/load.rs b/crates/shift-store/src/layer/load.rs new file mode 100644 index 00000000..f18f5f01 --- /dev/null +++ b/crates/shift-store/src/layer/load.rs @@ -0,0 +1,356 @@ +use std::collections::{HashMap, HashSet}; + +use rayon::prelude::*; +use rusqlite::{Connection, OptionalExtension, params}; +use shift_font as font; + +use super::{ + MAX_LAYER_PAYLOAD_BYTES, + directory::{map_directory_facts_row, validate_directory_facts}, + format::decode_layer, + payload::decompress_layer, + references::{validate_component_index, validate_component_rows}, +}; +use crate::{ShiftStore, StoreError}; + +pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; +pub const MAX_LAYER_READ_BATCH_DECODED_BYTES: u64 = 256 * 1024 * 1024; +const MAX_LAYER_PAYLOAD_BYTES_SQL: i64 = MAX_LAYER_PAYLOAD_BYTES as i64; + +impl ShiftStore { + /// Fetches, bounds-checks, decodes, and cross-checks one canonical layer. + pub fn load_glyph_layer( + &self, + layer_id: &font::LayerId, + ) -> Result, StoreError> { + load_glyph_layer_from_conn(&self.conn, layer_id) + } + + /// Fetches and cross-validates requested layers through count- and + /// decoded-byte-bounded batches. + pub fn load_glyph_layers( + &self, + layer_ids: &[font::LayerId], + ) -> Result, StoreError> { + load_glyph_layers_from_conn(&self.conn, layer_ids) + } +} + +pub(crate) fn load_glyph_layer_from_conn( + conn: &Connection, + layer_id: &font::LayerId, +) -> Result, StoreError> { + let mut directory_stmt = conn.prepare_cached( + " + SELECT l.id, l.source_id, l.width, l.height, + p.inner_format, p.compression, + p.stored_byte_length, p.decoded_byte_length, p.decoded_blake3, + length(p.payload) + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id = ?1 + ", + )?; + let facts = directory_stmt + .query_row([layer_id.to_string()], map_directory_facts_row) + .optional()?; + drop(directory_stmt); + let Some(facts) = facts else { + return Ok(None); + }; + facts.validate()?; + + // The length gate is deliberately a separate query so SQLite cannot + // allocate an unbounded Vec before the store applies its limit. + let mut payload_stmt = conn.prepare_cached( + " + SELECT payload + FROM glyph_layer_payloads + WHERE layer_id = ?1 + AND length(payload) = ?2 + AND length(payload) <= ?3 + ", + )?; + let payload: Vec = payload_stmt + .query_row( + params![ + layer_id.to_string(), + facts.actual_stored_byte_length, + MAX_LAYER_PAYLOAD_BYTES_SQL + ], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_string(), + detail: "payload changed during bounded read".to_string(), + })?; + let decoded = decompress_layer(facts.layer_id.as_str(), facts.stored_layer(payload)?)?; + let layer = decode_layer(&decoded)?; + validate_directory_facts( + layer_id, + &facts.source_id, + facts.width, + facts.height, + &layer, + )?; + validate_component_index(conn, &layer)?; + Ok(Some(layer)) +} + +pub(crate) fn load_glyph_layers_from_conn( + conn: &Connection, + layer_ids: &[font::LayerId], +) -> Result, StoreError> { + let mut keys = layer_ids + .iter() + .map(ToString::to_string) + .collect::>(); + keys.sort(); + keys.dedup(); + if keys.is_empty() { + return Ok(Vec::new()); + } + + let mut layers = Vec::with_capacity(keys.len()); + for count_batch in keys.chunks(MAX_LAYER_READ_BATCH_COUNT) { + let placeholders = (0..count_batch.len()) + .map(|_| "?") + .collect::>() + .join(","); + let sql = format!( + "SELECT l.id, p.decoded_byte_length + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id IN ({placeholders}) + ORDER BY l.id" + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(count_batch.iter()), |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + let mut decoded_lengths = HashMap::with_capacity(count_batch.len()); + for row in rows { + let (layer_id, decoded_byte_length) = row?; + if !(0..=MAX_LAYER_PAYLOAD_BYTES_SQL).contains(&decoded_byte_length) { + return Err(StoreError::LayerPayloadTooLarge { + bytes: u64::try_from(decoded_byte_length).unwrap_or(u64::MAX), + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }); + } + decoded_lengths.insert(layer_id, decoded_byte_length as u64); + } + drop(stmt); + + let mut batch = Vec::new(); + let mut batch_decoded_bytes = 0_u64; + for key in count_batch { + let decoded_byte_length = + decoded_lengths + .get(key) + .copied() + .ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: key.clone(), + })?; + if !batch.is_empty() + && batch_decoded_bytes.saturating_add(decoded_byte_length) + > MAX_LAYER_READ_BATCH_DECODED_BYTES + { + layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + batch.clear(); + batch_decoded_bytes = 0; + } + batch.push(font::LayerId::from_raw(key.clone())); + batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); + } + if !batch.is_empty() { + layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); + } + } + + Ok(layers) +} + +pub(super) fn load_glyph_layer_batch_from_conn( + conn: &Connection, + layer_ids: &[font::LayerId], +) -> Result, StoreError> { + let mut keys = layer_ids + .iter() + .map(ToString::to_string) + .collect::>(); + keys.sort(); + keys.dedup(); + if keys.is_empty() { + return Ok(Vec::new()); + } + if keys.len() > MAX_LAYER_READ_BATCH_COUNT { + return Err(StoreError::LayerReadBatchTooLarge { + layers: keys.len(), + limit: MAX_LAYER_READ_BATCH_COUNT, + }); + } + + let placeholders = (0..keys.len()).map(|_| "?").collect::>().join(","); + let directory_sql = format!( + " + SELECT l.id, l.source_id, l.width, l.height, + p.inner_format, p.compression, + p.stored_byte_length, p.decoded_byte_length, p.decoded_blake3, + length(p.payload) + FROM glyph_layers AS l + JOIN glyph_layer_payloads AS p ON p.layer_id = l.id + WHERE l.id IN ({placeholders}) + ORDER BY l.id + " + ); + let mut directory_stmt = conn.prepare(&directory_sql)?; + let directory_rows = directory_stmt.query_map( + rusqlite::params_from_iter(keys.iter()), + map_directory_facts_row, + )?; + let mut facts = HashMap::with_capacity(keys.len()); + let mut batch_decoded_bytes = 0_u64; + for row in directory_rows { + let facts_row = row?; + let decoded_byte_length = facts_row.validate()?; + batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); + facts.insert(facts_row.layer_id.clone(), facts_row); + } + drop(directory_stmt); + + for key in &keys { + if !facts.contains_key(key) { + return Err(StoreError::MissingEntity { + kind: "glyph layer", + id: key.clone(), + }); + } + } + if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { + return Err(StoreError::LayerReadBatchDecodedTooLarge { + bytes: batch_decoded_bytes, + limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, + }); + } + + let payload_sql = format!( + " + SELECT layer_id, payload, length(payload) + FROM glyph_layer_payloads + WHERE layer_id IN ({placeholders}) + AND length(payload) <= ? + ORDER BY layer_id + " + ); + let mut payload_params = keys + .iter() + .cloned() + .map(rusqlite::types::Value::Text) + .collect::>(); + payload_params.push(rusqlite::types::Value::Integer(MAX_LAYER_PAYLOAD_BYTES_SQL)); + let mut payload_stmt = conn.prepare(&payload_sql)?; + let payload_rows = + payload_stmt.query_map(rusqlite::params_from_iter(payload_params), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Vec>(1)?, + row.get::<_, i64>(2)?, + )) + })?; + let mut payloads = Vec::with_capacity(keys.len()); + for row in payload_rows { + let (layer_id, payload, actual_len) = row?; + let facts = facts + .get(&layer_id) + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.clone(), + detail: "payload has no directory row".to_string(), + })?; + if actual_len != facts.actual_stored_byte_length + || payload.len() as i64 != facts.actual_stored_byte_length + { + return Err(StoreError::LayerDirectoryMismatch { + layer_id, + detail: "payload changed during bounded batch read".to_string(), + }); + } + payloads.push((layer_id, payload)); + } + drop(payload_stmt); + + let decoded = payloads + .into_par_iter() + .map(|(layer_id, payload)| { + let facts = facts + .get(&layer_id) + .ok_or_else(|| StoreError::LayerDirectoryMismatch { + layer_id: layer_id.clone(), + detail: "payload has no directory row".to_string(), + })?; + let decoded = decompress_layer(layer_id.as_str(), facts.stored_layer(payload)?)?; + let layer = decode_layer(&decoded)?; + let expected_id = font::LayerId::from_raw(layer_id.clone()); + validate_directory_facts( + &expected_id, + &facts.source_id, + facts.width, + facts.height, + &layer, + )?; + Ok((layer_id, layer)) + }) + .collect::, StoreError>>()?; + let loaded = decoded + .iter() + .map(|(layer_id, _)| layer_id.clone()) + .collect::>(); + let layers = decoded + .into_iter() + .map(|(_, layer)| layer) + .collect::>(); + + for key in &keys { + if !loaded.contains(key) { + return Err(StoreError::LayerDirectoryMismatch { + layer_id: key.clone(), + detail: "payload changed during bounded batch read".to_string(), + }); + } + } + + let component_sql = format!( + " + SELECT layer_id, id, base_glyph_id, order_index + FROM glyph_components + WHERE layer_id IN ({placeholders}) + ORDER BY layer_id, order_index, id + " + ); + let mut component_stmt = conn.prepare(&component_sql)?; + let component_rows = + component_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + )) + })?; + let mut components: HashMap> = HashMap::new(); + for row in component_rows { + let (layer_id, component_id, base_glyph_id, order_index) = row?; + components + .entry(layer_id) + .or_default() + .push((component_id, base_glyph_id, order_index)); + } + drop(component_stmt); + + for layer in &layers { + let indexed = components.remove(layer.id().as_str()).unwrap_or_default(); + validate_component_rows(layer, &indexed)?; + } + Ok(layers) +} diff --git a/crates/shift-store/src/layer/mod.rs b/crates/shift-store/src/layer/mod.rs new file mode 100644 index 00000000..7f29803e --- /dev/null +++ b/crates/shift-store/src/layer/mod.rs @@ -0,0 +1,36 @@ +mod directory; +mod format; +mod load; +mod payload; +mod references; +mod write; + +#[cfg(test)] +mod tests; + +#[cfg(test)] +use load::load_glyph_layer_batch_from_conn; +#[cfg(test)] +use payload::{LayerPayloadCompression, decompress_layer}; + +pub use directory::GlyphLayerDirectoryEntry; +pub use format::GLYPH_LAYER_FORMAT; +pub use load::{MAX_LAYER_READ_BATCH_COUNT, MAX_LAYER_READ_BATCH_DECODED_BYTES}; + +pub(crate) use format::encode_layer; +pub(crate) use load::{load_glyph_layer_from_conn, load_glyph_layers_from_conn}; +pub(crate) use payload::{StoredLayerPayload, compress_layer}; +pub(crate) use write::{ + create_empty_layer_in_tx, rewrite_layer_in_tx, store_stored_layer_in_tx, write_layer_in_tx, +}; + +pub(crate) const MAX_LAYER_PAYLOAD_BYTES: usize = 256 * 1024 * 1024; + +fn check_layer_length(bytes: u64) -> Result<(), crate::StoreError> { + let limit = MAX_LAYER_PAYLOAD_BYTES as u64; + if bytes > limit { + return Err(crate::StoreError::LayerPayloadTooLarge { bytes, limit }); + } + + Ok(()) +} diff --git a/crates/shift-store/src/layer/payload.rs b/crates/shift-store/src/layer/payload.rs new file mode 100644 index 00000000..c1799b96 --- /dev/null +++ b/crates/shift-store/src/layer/payload.rs @@ -0,0 +1,216 @@ +use std::cell::RefCell; + +use super::{MAX_LAYER_PAYLOAD_BYTES, check_layer_length}; +use crate::StoreError; + +const ZSTD_COMPRESSION_LEVEL: i32 = 1; + +thread_local! { + static ZSTD_COMPRESSOR: RefCell>> = const { RefCell::new(None) }; + static ZSTD_DECOMPRESSOR: RefCell>> = const { RefCell::new(None) }; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LayerPayloadCompression { + None, + ZstandardV1, +} + +impl LayerPayloadCompression { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::None => "none", + Self::ZstandardV1 => "zstd.v1", + } + } +} + +impl TryFrom<&str> for LayerPayloadCompression { + type Error = StoreError; + + fn try_from(value: &str) -> Result { + match value { + "none" => Ok(Self::None), + "zstd.v1" => Ok(Self::ZstandardV1), + value => Err(StoreError::UnsupportedLayerCompression(value.to_owned())), + } + } +} + +pub(crate) struct StoredLayerPayload { + pub(crate) compression: LayerPayloadCompression, + pub(crate) bytes: Vec, + pub(crate) stored_byte_length: u64, + pub(crate) decoded_byte_length: u64, + pub(crate) decoded_blake3: [u8; 32], +} + +pub(crate) fn compress_layer(decoded: &[u8]) -> Result { + check_layer_length(decoded.len() as u64)?; + let compressed = ZSTD_COMPRESSOR.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some( + zstd::bulk::Compressor::new(ZSTD_COMPRESSION_LEVEL) + .map_err(|error| StoreError::LayerCompression(error.to_string()))?, + ); + } + slot.as_mut() + .expect("compressor was initialized") + .compress(decoded) + .map_err(|error| StoreError::LayerCompression(error.to_string())) + })?; + let decoded_byte_length = decoded.len() as u64; + let decoded_blake3 = *blake3::hash(decoded).as_bytes(); + + if compressed.len() < decoded.len() { + return Ok(StoredLayerPayload { + compression: LayerPayloadCompression::ZstandardV1, + stored_byte_length: compressed.len() as u64, + decoded_byte_length, + decoded_blake3, + bytes: compressed, + }); + } + + Ok(StoredLayerPayload { + compression: LayerPayloadCompression::None, + stored_byte_length: decoded_byte_length, + decoded_byte_length, + decoded_blake3, + bytes: decoded.to_vec(), + }) +} + +pub(crate) fn decompress_layer( + layer_id: &str, + stored: StoredLayerPayload, +) -> Result, StoreError> { + check_layer_length(stored.stored_byte_length)?; + check_layer_length(stored.decoded_byte_length)?; + if stored.bytes.len() as u64 != stored.stored_byte_length { + return Err(directory_mismatch( + layer_id, + format!( + "stored byte length {} != actual {}", + stored.stored_byte_length, + stored.bytes.len() + ), + )); + } + + let decoded = match stored.compression { + LayerPayloadCompression::None => stored.bytes, + LayerPayloadCompression::ZstandardV1 => { + let frame_length = + zstd::zstd_safe::find_frame_compressed_size(&stored.bytes).map_err(|code| { + StoreError::LayerDecompression(zstd::zstd_safe::get_error_name(code).to_owned()) + })?; + if frame_length != stored.bytes.len() { + return Err(directory_mismatch( + layer_id, + format!( + "zstd frame has {frame_length} bytes, stored payload has {}", + stored.bytes.len() + ), + )); + } + let decoded_capacity = usize::try_from(stored.decoded_byte_length).map_err(|_| { + StoreError::LayerPayloadTooLarge { + bytes: stored.decoded_byte_length, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + } + })?; + ZSTD_DECOMPRESSOR.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_none() { + *slot = Some( + zstd::bulk::Decompressor::new() + .map_err(|error| StoreError::LayerDecompression(error.to_string()))?, + ); + } + slot.as_mut() + .expect("decompressor was initialized") + .decompress(&stored.bytes, decoded_capacity) + .map_err(|error| StoreError::LayerDecompression(error.to_string())) + })? + } + }; + + if decoded.len() as u64 != stored.decoded_byte_length { + return Err(directory_mismatch( + layer_id, + format!( + "decoded byte length {} != actual {}", + stored.decoded_byte_length, + decoded.len() + ), + )); + } + if blake3::hash(&decoded).as_bytes() != &stored.decoded_blake3 { + return Err(directory_mismatch(layer_id, "decoded BLAKE3 mismatch")); + } + Ok(decoded) +} + +fn directory_mismatch(layer_id: &str, detail: impl Into) -> StoreError { + StoreError::LayerDirectoryMismatch { + layer_id: layer_id.to_owned(), + detail: detail.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incompressible_bytes_fall_back_to_none() { + let mut value = 0x1234_5678_u32; + let decoded = (0..64) + .map(|_| { + value = value.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (value >> 24) as u8 + }) + .collect::>(); + + let stored = compress_layer(&decoded).unwrap(); + + assert_eq!(stored.compression, LayerPayloadCompression::None); + assert_eq!(decompress_layer("layer_test", stored).unwrap(), decoded); + } + + #[test] + fn compressed_bytes_reject_truncation_trailing_data_and_wrong_hash() { + let decoded = vec![0; 4096]; + let mut truncated = compress_layer(&decoded).unwrap(); + assert_eq!(truncated.compression, LayerPayloadCompression::ZstandardV1); + truncated.bytes.pop(); + truncated.stored_byte_length -= 1; + assert!(decompress_layer("layer_test", truncated).is_err()); + + let mut trailing = compress_layer(&decoded).unwrap(); + trailing.bytes.push(0); + trailing.stored_byte_length += 1; + assert!(decompress_layer("layer_test", trailing).is_err()); + + let mut wrong_hash = compress_layer(&decoded).unwrap(); + wrong_hash.decoded_blake3[0] ^= 1; + assert!(decompress_layer("layer_test", wrong_hash).is_err()); + } + + #[test] + fn unsupported_compression_and_oversized_decode_are_rejected() { + assert!(matches!( + LayerPayloadCompression::try_from("zstd.v2"), + Err(StoreError::UnsupportedLayerCompression(_)) + )); + + let mut stored = compress_layer(&vec![0; 4096]).unwrap(); + stored.decoded_byte_length = MAX_LAYER_PAYLOAD_BYTES as u64 + 1; + assert!(matches!( + decompress_layer("layer_test", stored), + Err(StoreError::LayerPayloadTooLarge { .. }) + )); + } +} diff --git a/crates/shift-store/src/layer/references.rs b/crates/shift-store/src/layer/references.rs new file mode 100644 index 00000000..11fe0a9c --- /dev/null +++ b/crates/shift-store/src/layer/references.rs @@ -0,0 +1,154 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use rusqlite::Connection; +use shift_font as font; + +use crate::{ShiftStore, StoreError}; + +impl ShiftStore { + /// Returns every direct glyph-component edge from one ordered relational + /// scan. Glyphs without components are absent from the map. + pub fn glyph_component_references( + &self, + ) -> Result>, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT l.glyph_id, c.base_glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS l ON l.id = c.layer_id + ORDER BY l.glyph_id, c.base_glyph_id + ", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + font::GlyphId::from_raw(row.get::<_, String>(0)?), + font::GlyphId::from_raw(row.get::<_, String>(1)?), + )) + })?; + let mut references = HashMap::new(); + for row in rows { + let (glyph_id, base_glyph_id) = row?; + references + .entry(glyph_id) + .or_insert_with(Vec::new) + .push(base_glyph_id); + } + Ok(references) + } + + /// Returns referenced glyph identities entirely from the relational index. + pub fn referenced_glyph_ids_for_glyph( + &self, + glyph_id: &font::GlyphId, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT c.base_glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS l ON l.id = c.layer_id + WHERE l.glyph_id = ?1 + ORDER BY c.base_glyph_id + ", + )?; + let rows = stmt.query_map([glyph_id.to_string()], |row| { + Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) + })?; + rows.collect::, _>>().map_err(Into::into) + } + + /// Computes the transitive component closure without scanning payloads. + pub fn referenced_glyph_closure( + &self, + roots: impl IntoIterator, + ) -> Result, StoreError> { + let mut pending: VecDeque<_> = roots.into_iter().collect(); + let mut seen = HashSet::new(); + let mut result = Vec::new(); + while let Some(glyph_id) = pending.pop_front() { + if !seen.insert(glyph_id.clone()) { + continue; + } + pending.extend(self.referenced_glyph_ids_for_glyph(&glyph_id)?); + result.push(glyph_id); + } + Ok(result) + } + + /// Reverse-reference lookup used for dependent invalidation. + pub fn dependent_glyph_ids_for_layers( + &self, + layer_ids: &[font::LayerId], + ) -> Result, StoreError> { + if layer_ids.is_empty() { + return Ok(Vec::new()); + } + let mut result = HashSet::new(); + let mut stmt = self.conn.prepare( + " + SELECT DISTINCT owner.glyph_id + FROM glyph_components AS c + JOIN glyph_layers AS target ON target.glyph_id = c.base_glyph_id + JOIN glyph_layers AS owner ON owner.id = c.layer_id + WHERE target.id = ?1 + ", + )?; + for layer_id in layer_ids { + let rows = stmt.query_map([layer_id.to_string()], |row| { + Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) + })?; + for row in rows { + result.insert(row?); + } + } + let mut result: Vec<_> = result.into_iter().collect(); + result.sort_by(|left, right| left.as_str().cmp(right.as_str())); + Ok(result) + } +} + +pub(super) fn validate_component_index( + conn: &Connection, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let mut stmt = conn.prepare_cached( + " + SELECT id, base_glyph_id, order_index + FROM glyph_components + WHERE layer_id = ?1 + ORDER BY order_index, id + ", + )?; + let indexed = stmt + .query_map([layer.id().to_string()], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + })? + .collect::, _>>()?; + validate_component_rows(layer, &indexed) +} + +pub(super) fn validate_component_rows( + layer: &font::GlyphLayer, + indexed: &[(String, String, i64)], +) -> Result<(), StoreError> { + let authored: Vec<_> = layer + .components_iter() + .enumerate() + .map(|(order_index, component)| { + ( + component.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ) + }) + .collect(); + if indexed != authored { + return Err(StoreError::StaleLayerReferenceIndex { + layer_id: layer.id().to_string(), + }); + } + Ok(()) +} diff --git a/crates/shift-store/src/layer/tests.rs b/crates/shift-store/src/layer/tests.rs new file mode 100644 index 00000000..a56e8e6d --- /dev/null +++ b/crates/shift-store/src/layer/tests.rs @@ -0,0 +1,669 @@ +use std::time::{Duration, Instant}; + +use rusqlite::params; +use shift_font as font; + +use super::*; +use crate::{ShiftStore, StoreError, WorkspaceState, write_mode::WriteMode}; + +fn populated_store() -> (ShiftStore, font::Font) { + let mut store = ShiftStore::open_memory_for_test().unwrap(); + let font = font::test_support::sample_font(); + store.replace_font_state(&font).unwrap(); + store + .set_workspace_state(WorkspaceState::untitled(None)) + .unwrap(); + (store, font) +} + +#[test] +fn sqlite_payload_preserves_canonical_bytes() { + let font = font::test_support::sample_font(); + let layer = font + .glyph_by_name("A") + .unwrap() + .layers() + .values() + .next() + .unwrap(); + let encoded = encode_layer(layer).unwrap(); + let glyph_id = font::GlyphId::from_raw("storage_golden"); + let glyph_name = font::GlyphName::from("storageGolden"); + let mut store = ShiftStore::open_memory_for_test().unwrap(); + store + .conn + .execute( + "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'Golden', 'master', 0)", + [layer.source_id().to_string()], + ) + .unwrap(); + store + .conn + .execute( + "INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, 0)", + params![glyph_id.to_string(), glyph_name.as_str()], + ) + .unwrap(); + + let tx = store.conn.transaction().unwrap(); + write_layer_in_tx(&tx, &glyph_id, layer).unwrap(); + tx.commit().unwrap(); + + let stored = store + .conn + .query_row( + "SELECT compression, payload, stored_byte_length, + decoded_byte_length, decoded_blake3 + FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| { + Ok(StoredLayerPayload { + compression: match row.get::<_, String>(0)?.as_str() { + "zstd.v1" => LayerPayloadCompression::ZstandardV1, + _ => LayerPayloadCompression::None, + }, + bytes: row.get(1)?, + stored_byte_length: row.get::<_, i64>(2)? as u64, + decoded_byte_length: row.get::<_, i64>(3)? as u64, + decoded_blake3: row.get::<_, Vec>(4)?.try_into().unwrap(), + }) + }, + ) + .unwrap(); + assert_eq!(stored.compression, LayerPayloadCompression::ZstandardV1); + assert!(stored.stored_byte_length < stored.decoded_byte_length); + assert_eq!( + decompress_layer(layer.id().as_str(), stored).unwrap(), + encoded + ); + let decoded = store.load_glyph_layer(&layer.id()).unwrap().unwrap(); + assert_eq!(encode_layer(&decoded).unwrap(), encoded); +} + +#[test] +fn bounded_batch_load_matches_complete_font_layers() { + let (store, font) = populated_store(); + let mut layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .collect::>(); + layer_ids.push(layer_ids[0].clone()); + + let layers = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(layers.len(), layer_ids.len() - 1); + for layer in layers { + assert_eq!(font.layer(layer.id()).unwrap(), &layer); + } +} + +#[test] +fn requested_layers_load_across_multiple_count_bounded_batches() { + let mut font = font::Font::new(); + let source_id = font.default_source_id().unwrap(); + let mut layer_ids = Vec::new(); + for index in 0..=MAX_LAYER_READ_BATCH_COUNT { + let layer = font::GlyphLayer::with_width( + font::LayerId::from_raw(format!("layer_{index:04}")), + source_id.clone(), + index as f64, + ); + layer_ids.push(layer.id()); + let mut glyph = font::Glyph::new(format!("glyph_{index:04}")); + glyph.set_layer(layer); + font.insert_glyph(glyph).unwrap(); + } + let mut store = ShiftStore::open_memory_for_test().unwrap(); + store.replace_font_state(&font).unwrap(); + + let layers = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(layers.len(), layer_ids.len()); + assert_eq!(layers.first().unwrap().id(), layer_ids[0]); + assert_eq!(layers.last().unwrap().id(), layer_ids[512]); +} + +#[test] +fn one_internal_batch_rejects_excessive_layer_counts_before_sql() { + let store = ShiftStore::open_memory_for_test().unwrap(); + let layer_ids = (0..=MAX_LAYER_READ_BATCH_COUNT) + .map(|index| font::LayerId::from_raw(format!("layer_{index}"))) + .collect::>(); + + assert!(matches!( + load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), + Err(StoreError::LayerReadBatchTooLarge { .. }) + )); +} + +#[test] +fn batch_bounds_declared_decoded_bytes_before_fetching_payloads() { + let (store, font) = populated_store(); + let layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .take(2) + .collect::>(); + let decoded_byte_length = MAX_LAYER_READ_BATCH_DECODED_BYTES / 2 + 1; + for layer_id in &layer_ids { + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = ?1 + WHERE layer_id = ?2", + params![decoded_byte_length as i64, layer_id.to_string()], + ) + .unwrap(); + } + + assert!(matches!( + load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), + Err(StoreError::LayerReadBatchDecodedTooLarge { .. }) + )); +} + +#[test] +fn mixed_compressed_and_uncompressed_layers_load_in_stable_order() { + let (store, font) = populated_store(); + let mut layer_ids = font + .glyphs() + .flat_map(|glyph| glyph.layers().keys().cloned()) + .take(2) + .collect::>(); + layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); + let uncompressed_layer = font.layer(layer_ids[0].clone()).unwrap(); + let encoded = encode_layer(uncompressed_layer).unwrap(); + let decoded_blake3 = blake3::hash(&encoded); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET compression = 'none', payload = ?1, + stored_byte_length = ?2, decoded_byte_length = ?2, + decoded_blake3 = ?3 + WHERE layer_id = ?4", + params![ + encoded.as_slice(), + encoded.len() as i64, + decoded_blake3.as_bytes().as_slice(), + layer_ids[0].to_string(), + ], + ) + .unwrap(); + + let loaded = store.load_glyph_layers(&layer_ids).unwrap(); + + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].id(), layer_ids[0]); + assert_eq!(loaded[1].id(), layer_ids[1]); + for layer in loaded { + assert_eq!(font.layer(layer.id()).unwrap(), &layer); + } +} + +#[test] +fn declared_stored_length_mismatch_is_rejected_before_decode() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .pragma_update(None, "ignore_check_constraints", "ON") + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET stored_byte_length = stored_byte_length + 1 + WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + store + .conn + .pragma_update(None, "ignore_check_constraints", "OFF") + .unwrap(); + + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("declared stored byte length") + )); + assert!(matches!( + store.load_glyph_layers(std::slice::from_ref(layer_id)), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("declared stored byte length") + )); +} + +#[test] +fn layer_rewrite_transitions_from_uncompressed_to_zstd_consistently() { + let (mut store, font) = populated_store(); + let layer = font + .glyphs() + .next() + .unwrap() + .layers() + .values() + .next() + .unwrap(); + let mut compressible_layer = layer.as_ref().clone(); + compressible_layer.lib_mut().set( + "com.shift.compression-fixture".to_string(), + font::LibValue::Data(vec![0; 8 * 1024]), + ); + let encoded = encode_layer(&compressible_layer).unwrap(); + let uncompressed = StoredLayerPayload { + compression: LayerPayloadCompression::None, + bytes: encoded.clone(), + stored_byte_length: encoded.len() as u64, + decoded_byte_length: encoded.len() as u64, + decoded_blake3: *blake3::hash(&encoded).as_bytes(), + }; + let glyph_id = font.glyph_id_by_layer(layer.id()).unwrap(); + let tx = store.conn.transaction().unwrap(); + store_stored_layer_in_tx( + &tx, + &glyph_id, + &compressible_layer, + &uncompressed, + WriteMode::Upsert, + ) + .unwrap(); + tx.commit().unwrap(); + assert_eq!( + store + .conn + .query_row( + "SELECT compression FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "none" + ); + + store.replace_glyph_layer(&compressible_layer).unwrap(); + + let (compression, stored, decoded, hash_length, payload_length) = store + .conn + .query_row( + "SELECT compression, stored_byte_length, decoded_byte_length, + length(decoded_blake3), length(payload) + FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer.id().to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }, + ) + .unwrap(); + assert_eq!(compression, "zstd.v1"); + assert_eq!(stored, payload_length); + assert!(stored < decoded); + assert_eq!(hash_length, 32); + assert_eq!( + store.load_glyph_layer(&layer.id()).unwrap().unwrap(), + compressible_layer + ); +} + +#[test] +fn false_decoded_length_and_hash_are_rejected() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = decoded_byte_length + 1 + WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("decoded byte length") + )); + + let encoded = encode_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET decoded_byte_length = ?1, decoded_blake3 = zeroblob(32) + WHERE layer_id = ?2", + params![encoded.len() as i64, layer_id.to_string()], + ) + .unwrap(); + assert!(matches!( + store.load_glyph_layer(layer_id), + Err(StoreError::LayerDirectoryMismatch { detail, .. }) + if detail.contains("decoded BLAKE3 mismatch") + )); +} + +#[test] +fn directory_open_never_reads_malformed_payloads() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET payload = X'00', stored_byte_length = 1 + WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + + let directory = store.load_font_directory().unwrap(); + assert_eq!(directory.glyph_count(), font.glyph_count()); + assert!(directory.layer(layer_id.clone()).unwrap().is_empty()); + assert!(store.load_glyph_layer(layer_id).is_err()); + assert!( + store + .load_glyph_layers(std::slice::from_ref(layer_id)) + .is_err() + ); +} + +#[test] +fn directory_retains_layer_when_payload_row_is_missing() { + let (store, font) = populated_store(); + let layer_id = font + .glyphs() + .next() + .unwrap() + .layers() + .keys() + .next() + .unwrap(); + store + .conn + .execute( + "DELETE FROM glyph_layer_payloads WHERE layer_id = ?1", + [layer_id.to_string()], + ) + .unwrap(); + + let directory = store.load_font_directory().unwrap(); + + assert!(directory.layer(layer_id.clone()).is_some()); + assert!(store.load_font_state().is_err()); +} + +#[test] +fn stale_reference_index_is_rejected() { + let (store, font) = populated_store(); + let layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap(); + store + .conn + .execute( + "DELETE FROM glyph_components WHERE layer_id = ?1", + [layer.id().to_string()], + ) + .unwrap(); + + assert!(matches!( + store.load_glyph_layer(&layer.id()), + Err(StoreError::StaleLayerReferenceIndex { .. }) + )); + assert!(matches!( + store.load_glyph_layers(&[layer.id()]), + Err(StoreError::StaleLayerReferenceIndex { .. }) + )); +} + +#[test] +fn index_constraint_failure_rolls_back_payload_directory_and_revision() { + let (mut store, font) = populated_store(); + let indexed_layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap(); + let duplicate = indexed_layer.components_iter().next().unwrap(); + let mut target = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| layer.id() != indexed_layer.id() && layer.components().is_empty()) + .unwrap() + .as_ref() + .clone(); + target.add_component(font::Component::with_id( + duplicate.id(), + duplicate.base_glyph_id(), + duplicate.base_glyph_name().clone(), + *duplicate.transform(), + )); + + let before_payload: Vec = store + .conn + .query_row( + "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + [target.id().to_string()], + |row| row.get(0), + ) + .unwrap(); + let before_revision = store.workspace_state().unwrap().unwrap().revision; + + assert!(store.replace_glyph_layer(&target).is_err()); + + let after_payload: Vec = store + .conn + .query_row( + "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", + [target.id().to_string()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(after_payload, before_payload); + assert_eq!( + store.workspace_state().unwrap().unwrap().revision, + before_revision + ); +} + +#[test] +fn replacement_updates_payload_directory_references_and_revision_atomically() { + let (mut store, font) = populated_store(); + let mut layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .find(|layer| !layer.components().is_empty()) + .unwrap() + .as_ref() + .clone(); + let owner = font.glyph_id_by_layer(layer.id()).unwrap(); + layer.set_width(layer.width() + 23.0); + layer.clear_components(); + + store.replace_glyph_layer(&layer).unwrap(); + + assert_eq!( + store.load_glyph_layer(&layer.id()).unwrap(), + Some(layer.clone()) + ); + let directory = store + .list_glyph_layer_directory() + .unwrap() + .into_iter() + .find(|entry| entry.layer_id == layer.id()) + .unwrap(); + assert_eq!(directory.width.to_bits(), layer.width().to_bits()); + assert!( + store + .referenced_glyph_ids_for_glyph(&owner) + .unwrap() + .is_empty() + ); + assert_eq!(store.workspace_state().unwrap().unwrap().revision, 1); +} + +#[test] +fn post_font_change_set_rewrites_touched_layer_without_decoding_old_payload() { + let (mut store, mut post_font) = populated_store(); + let mut layer = post_font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .next() + .unwrap() + .as_ref() + .clone(); + layer.set_width(layer.width() + 41.0); + post_font.replace_glyph_layers(vec![layer.clone()]).unwrap(); + store + .conn + .execute( + "UPDATE glyph_layer_payloads + SET payload = X'00', stored_byte_length = 1 + WHERE layer_id = ?1", + [layer.id().to_string()], + ) + .unwrap(); + let changes = font::FontChangeSet::new(vec![font::FontChange::layer_metrics_changed(&layer)]); + + store + .apply_change_set_with_font(&changes, &post_font) + .unwrap(); + + assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); +} + +#[test] +fn abandoned_transaction_preserves_last_committed_layer() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("working.sqlite"); + let font = font::test_support::sample_font(); + let layer = font + .glyphs() + .flat_map(|glyph| glyph.layers().values()) + .next() + .unwrap() + .as_ref() + .clone(); + let mut store = ShiftStore::open(&path).unwrap(); + store.replace_font_state(&font).unwrap(); + let mut uncommitted = layer.clone(); + uncommitted.set_width(layer.width() + 999.0); + { + let tx = store.conn.transaction().unwrap(); + rewrite_layer_in_tx(&tx, &uncommitted).unwrap(); + // Dropping an open transaction models recovery from a process + // that exits before COMMIT reaches the WAL. + } + drop(store); + + let reopened = ShiftStore::open(&path).unwrap(); + assert_eq!(reopened.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); +} + +#[test] +fn abandoned_font_stream_rolls_back_header_and_glyphs() { + let (mut store, font) = populated_store(); + let before = store.list_glyph_layer_directory().unwrap(); + { + let mut writer = store.begin_import(&font).unwrap(); + let glyph = font.glyphs().next().unwrap(); + writer + .write_glyph_batch(std::slice::from_ref(glyph)) + .unwrap(); + } + + assert_eq!(store.list_glyph_layer_directory().unwrap(), before); +} + +#[test] +fn cjk_scale_directory_open_is_payload_independent() { + const GLYPH_COUNT: usize = 65_536; + let mut store = ShiftStore::open_memory_for_test().unwrap(); + let source_id = font::SourceId::from_raw("cjk").to_string(); + store + .conn + .execute( + "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'CJK', 'master', 0)", + [&source_id], + ) + .unwrap(); + let tx = store.conn.transaction().unwrap(); + { + let mut glyph_stmt = tx + .prepare("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)") + .unwrap(); + let mut layer_stmt = tx + .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, 1000, NULL)") + .unwrap(); + let mut payload_stmt = tx + .prepare( + "INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, 'none', X'00', 1, 1, zeroblob(32))", + ) + .unwrap(); + for index in 0..GLYPH_COUNT { + let glyph_id = font::GlyphId::from_raw(format!("cjk{index}")).to_string(); + let layer_id = font::LayerId::from_raw(format!("cjk{index}")).to_string(); + let name = format!("uni{index:05X}"); + glyph_stmt + .execute(params![glyph_id, name, index as i64]) + .unwrap(); + layer_stmt + .execute(params![layer_id, glyph_id, source_id]) + .unwrap(); + payload_stmt + .execute(params![layer_id, GLYPH_LAYER_FORMAT]) + .unwrap(); + } + } + tx.commit().unwrap(); + + let started = Instant::now(); + let directory = store.load_font_directory().unwrap(); + let elapsed = started.elapsed(); + + assert_eq!(directory.glyph_count(), GLYPH_COUNT); + assert_eq!( + directory + .glyphs() + .map(|glyph| glyph.layers().len()) + .sum::(), + GLYPH_COUNT + ); + assert!( + elapsed < Duration::from_secs(15), + "directory open took {elapsed:?}" + ); +} diff --git a/crates/shift-store/src/layer/write.rs b/crates/shift-store/src/layer/write.rs new file mode 100644 index 00000000..2e42e6de --- /dev/null +++ b/crates/shift-store/src/layer/write.rs @@ -0,0 +1,178 @@ +use rusqlite::{Connection, OptionalExtension, Transaction, params}; +use shift_font as font; + +use super::{ + GLYPH_LAYER_FORMAT, MAX_LAYER_PAYLOAD_BYTES, + format::encode_layer, + payload::{StoredLayerPayload, compress_layer}, +}; +use crate::{ + ShiftStore, StoreError, workspace_state::mark_workspace_dirty_in_tx, write_mode::WriteMode, +}; + +impl ShiftStore { + /// Replaces one independently-addressed layer and its earned reference + /// rows in one transaction. The workspace revision advances only after + /// encoding and index derivation have succeeded. + pub fn replace_glyph_layer(&mut self, layer: &font::GlyphLayer) -> Result<(), StoreError> { + let owner = + layer_owner(&self.conn, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer.id().to_string(), + })?; + let tx = self.conn.transaction()?; + write_layer_in_tx(&tx, &owner, layer)?; + mark_workspace_dirty_in_tx(&tx)?; + tx.commit()?; + Ok(()) + } +} + +pub(crate) fn write_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let encoded = encode_layer(layer)?; + let stored = compress_layer(&encoded)?; + store_stored_layer_in_tx(tx, glyph_id, layer, &stored, WriteMode::Upsert) +} + +pub(crate) fn store_stored_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + layer: &font::GlyphLayer, + stored: &StoredLayerPayload, + mode: WriteMode, +) -> Result<(), StoreError> { + let stored_byte_length = encoded_len(stored.stored_byte_length)?; + let decoded_byte_length = encoded_len(stored.decoded_byte_length)?; + let layer_sql = match mode { + WriteMode::Insert => { + "INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, ?4, ?5)" + } + WriteMode::Upsert => { + " + INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(id) DO UPDATE SET + glyph_id = excluded.glyph_id, + source_id = excluded.source_id, + width = excluded.width, + height = excluded.height + " + } + }; + tx.prepare_cached(layer_sql)?.execute(params![ + layer.id().to_string(), + glyph_id.to_string(), + layer.source_id().to_string(), + layer.width(), + layer.height(), + ])?; + + let payload_sql = match mode { + WriteMode::Insert => { + "INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" + } + WriteMode::Upsert => { + " + INSERT INTO glyph_layer_payloads ( + layer_id, inner_format, compression, payload, + stored_byte_length, decoded_byte_length, decoded_blake3 + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(layer_id) DO UPDATE SET + inner_format = excluded.inner_format, + compression = excluded.compression, + payload = excluded.payload, + stored_byte_length = excluded.stored_byte_length, + decoded_byte_length = excluded.decoded_byte_length, + decoded_blake3 = excluded.decoded_blake3 + " + } + }; + tx.prepare_cached(payload_sql)?.execute(params![ + layer.id().to_string(), + GLYPH_LAYER_FORMAT, + stored.compression.as_str(), + stored.bytes.as_slice(), + stored_byte_length, + decoded_byte_length, + stored.decoded_blake3.as_slice(), + ])?; + write_component_index(tx, layer, mode) +} + +fn encoded_len(bytes: u64) -> Result { + i64::try_from(bytes).map_err(|_| StoreError::LayerPayloadTooLarge { + bytes, + limit: MAX_LAYER_PAYLOAD_BYTES as u64, + }) +} + +pub(crate) fn rewrite_layer_in_tx( + tx: &Transaction<'_>, + layer: &font::GlyphLayer, +) -> Result<(), StoreError> { + let owner = layer_owner(tx, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { + kind: "glyph layer", + id: layer.id().to_string(), + })?; + write_layer_in_tx(tx, &owner, layer) +} + +pub(crate) fn create_empty_layer_in_tx( + tx: &Transaction<'_>, + glyph_id: &font::GlyphId, + layer_id: font::LayerId, + source_id: font::SourceId, + width: f64, + height: Option, +) -> Result<(), StoreError> { + let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); + layer.set_height(height); + write_layer_in_tx(tx, glyph_id, &layer) +} + +fn write_component_index( + tx: &Transaction<'_>, + layer: &font::GlyphLayer, + mode: WriteMode, +) -> Result<(), StoreError> { + if mode == WriteMode::Upsert { + tx.prepare_cached("DELETE FROM glyph_components WHERE layer_id = ?1")? + .execute([layer.id().to_string()])?; + } + for (order_index, component) in layer.components_iter().enumerate() { + tx.prepare_cached( + " + INSERT INTO glyph_components ( + id, layer_id, base_glyph_id, order_index + ) VALUES (?1, ?2, ?3, ?4) + ", + )? + .execute(params![ + component.id().to_string(), + layer.id().to_string(), + component.base_glyph_id().to_string(), + order_index as i64, + ])?; + } + Ok(()) +} + +fn layer_owner( + conn: &Connection, + layer_id: &font::LayerId, +) -> Result, StoreError> { + conn.query_row( + "SELECT glyph_id FROM glyph_layers WHERE id = ?1", + [layer_id.to_string()], + |row| Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)), + ) + .optional() + .map_err(Into::into) +} diff --git a/crates/shift-store/src/lib.rs b/crates/shift-store/src/lib.rs index 6c47466c..5fb6a706 100644 --- a/crates/shift-store/src/lib.rs +++ b/crates/shift-store/src/lib.rs @@ -4,12 +4,11 @@ mod error; mod font; mod font_state; mod glyph; -mod packed_layer; +mod import_writer; +mod layer; mod schema; mod source; mod store; -mod stored_layer; -mod stream_writer; mod types; mod workspace_state; mod write_mode; @@ -17,13 +16,13 @@ mod write_mode; pub use error::StoreError; pub use font::FontInfo; pub use glyph::{GlyphRecord, NewGlyph}; -pub use packed_layer::{ +pub use import_writer::{FontImportWriter, encode_glyph_batch}; +pub use layer::{ GLYPH_LAYER_FORMAT, GlyphLayerDirectoryEntry, MAX_LAYER_READ_BATCH_COUNT, MAX_LAYER_READ_BATCH_DECODED_BYTES, }; pub use source::{AxisRecord, NewAxis, NewSource, SourceAxisLocation, SourceKind, SourceRecord}; pub use store::ShiftStore; -pub use stream_writer::{FontImportWriter, encode_glyph_batch}; pub use types::{AxisId, GlyphId, GlyphWriteBatch, LayerBatchTiming, RevisionId, SourceId}; pub use workspace_state::{ Evidence, FileIdentity, SourceIdentitySnapshot, WorkspaceSourceKind, WorkspaceState, diff --git a/crates/shift-store/src/packed_layer.rs b/crates/shift-store/src/packed_layer.rs deleted file mode 100644 index c27fd6ad..00000000 --- a/crates/shift-store/src/packed_layer.rs +++ /dev/null @@ -1,1523 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; - -use rayon::prelude::*; -use rusqlite::{Connection, OptionalExtension, Transaction, params}; -use shift_font as font; - -use crate::{ - ShiftStore, StoreError, - stored_layer::{ - MAX_LAYER_PAYLOAD_BYTES as MAX_LAYER_PAYLOAD_BYTES_USIZE, compress_glyph_layer, - decode_layer, decompress_glyph_layer, encode_layer, - }, - types::{LayerPayloadCompression, StoredLayerPayload}, - workspace_state::mark_workspace_dirty_in_tx, - write_mode::WriteMode, -}; - -pub const GLYPH_LAYER_FORMAT: &str = "shift.glyph-layer.v1"; -pub const MAX_LAYER_READ_BATCH_COUNT: usize = 512; -pub const MAX_LAYER_READ_BATCH_DECODED_BYTES: u64 = 256 * 1024 * 1024; -const MAX_LAYER_PAYLOAD_BYTES: i64 = MAX_LAYER_PAYLOAD_BYTES_USIZE as i64; - -/// Relational facts needed to locate a layer without reading its payload. -#[derive(Clone, Debug, PartialEq)] -pub struct GlyphLayerDirectoryEntry { - pub layer_id: font::LayerId, - pub glyph_id: font::GlyphId, - pub source_id: font::SourceId, - pub name: Option, - pub width: f64, - pub height: Option, - pub stored_byte_length: u64, - pub decoded_byte_length: u64, -} - -impl ShiftStore { - /// Fetches, bounds-checks, decodes, and cross-checks one canonical layer. - pub fn load_glyph_layer( - &self, - layer_id: &font::LayerId, - ) -> Result, StoreError> { - load_glyph_layer_from_conn(&self.conn, layer_id) - } - - /// Fetches and cross-validates requested layers through count- and - /// decoded-byte-bounded batches. - pub fn load_glyph_layers( - &self, - layer_ids: &[font::LayerId], - ) -> Result, StoreError> { - load_glyph_layers_from_conn(&self.conn, layer_ids) - } - - /// Replaces one independently-addressed layer and its earned reference - /// rows in one transaction. The workspace revision advances only after - /// encoding and index derivation have succeeded. - pub fn replace_glyph_layer(&mut self, layer: &font::GlyphLayer) -> Result<(), StoreError> { - let owner = - layer_owner(&self.conn, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: layer.id().to_string(), - })?; - let tx = self.conn.transaction()?; - write_layer_in_tx(&tx, &owner, layer)?; - mark_workspace_dirty_in_tx(&tx)?; - tx.commit()?; - Ok(()) - } - - pub fn list_glyph_layer_directory(&self) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT l.id, l.glyph_id, l.source_id, g.name, l.width, l.height, - p.stored_byte_length, p.decoded_byte_length - FROM glyph_layers AS l - JOIN glyphs AS g ON g.id = l.glyph_id - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - ORDER BY l.glyph_id, l.source_id, l.id - ", - )?; - let rows = stmt.query_map([], map_directory_row)?; - rows.collect::, _>>().map_err(Into::into) - } - - /// Returns every direct glyph-component edge from one ordered relational - /// scan. Glyphs without components are absent from the map. - pub fn glyph_component_references( - &self, - ) -> Result>, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT DISTINCT l.glyph_id, c.base_glyph_id - FROM glyph_components AS c - JOIN glyph_layers AS l ON l.id = c.layer_id - ORDER BY l.glyph_id, c.base_glyph_id - ", - )?; - let rows = stmt.query_map([], |row| { - Ok(( - font::GlyphId::from_raw(row.get::<_, String>(0)?), - font::GlyphId::from_raw(row.get::<_, String>(1)?), - )) - })?; - let mut references = HashMap::new(); - for row in rows { - let (glyph_id, base_glyph_id) = row?; - references - .entry(glyph_id) - .or_insert_with(Vec::new) - .push(base_glyph_id); - } - Ok(references) - } - - /// Returns referenced glyph identities entirely from the relational index. - pub fn referenced_glyph_ids_for_glyph( - &self, - glyph_id: &font::GlyphId, - ) -> Result, StoreError> { - let mut stmt = self.conn.prepare( - " - SELECT DISTINCT c.base_glyph_id - FROM glyph_components AS c - JOIN glyph_layers AS l ON l.id = c.layer_id - WHERE l.glyph_id = ?1 - ORDER BY c.base_glyph_id - ", - )?; - let rows = stmt.query_map([glyph_id.to_string()], |row| { - Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) - })?; - rows.collect::, _>>().map_err(Into::into) - } - - /// Computes the transitive component closure without scanning payloads. - pub fn referenced_glyph_closure( - &self, - roots: impl IntoIterator, - ) -> Result, StoreError> { - let mut pending: VecDeque<_> = roots.into_iter().collect(); - let mut seen = HashSet::new(); - let mut result = Vec::new(); - while let Some(glyph_id) = pending.pop_front() { - if !seen.insert(glyph_id.clone()) { - continue; - } - pending.extend(self.referenced_glyph_ids_for_glyph(&glyph_id)?); - result.push(glyph_id); - } - Ok(result) - } - - /// Reverse-reference lookup used for dependent invalidation. - pub fn dependent_glyph_ids_for_layers( - &self, - layer_ids: &[font::LayerId], - ) -> Result, StoreError> { - if layer_ids.is_empty() { - return Ok(Vec::new()); - } - let mut result = HashSet::new(); - let mut stmt = self.conn.prepare( - " - SELECT DISTINCT owner.glyph_id - FROM glyph_components AS c - JOIN glyph_layers AS target ON target.glyph_id = c.base_glyph_id - JOIN glyph_layers AS owner ON owner.id = c.layer_id - WHERE target.id = ?1 - ", - )?; - for layer_id in layer_ids { - let rows = stmt.query_map([layer_id.to_string()], |row| { - Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)) - })?; - for row in rows { - result.insert(row?); - } - } - let mut result: Vec<_> = result.into_iter().collect(); - result.sort_by(|left, right| left.as_str().cmp(right.as_str())); - Ok(result) - } -} - -struct DirectoryFacts { - layer_id: String, - source_id: String, - width: f64, - height: Option, - inner_format: String, - compression: String, - stored_byte_length: i64, - decoded_byte_length: i64, - decoded_blake3: Vec, - actual_stored_byte_length: i64, -} - -impl DirectoryFacts { - fn validate(&self) -> Result { - if self.inner_format != GLYPH_LAYER_FORMAT { - return Err(StoreError::UnsupportedLayerFormat( - self.inner_format.clone(), - )); - } - LayerPayloadCompression::try_from(self.compression.as_str())?; - if self.stored_byte_length != self.actual_stored_byte_length { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: self.layer_id.clone(), - detail: format!( - "declared stored byte length {} != actual {}", - self.stored_byte_length, self.actual_stored_byte_length - ), - }); - } - for byte_length in [self.stored_byte_length, self.decoded_byte_length] { - if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&byte_length) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(byte_length).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } - } - if self.stored_byte_length > self.decoded_byte_length { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: self.layer_id.clone(), - detail: format!( - "stored byte length {} exceeds decoded byte length {}", - self.stored_byte_length, self.decoded_byte_length - ), - }); - } - if self.decoded_blake3.len() != 32 { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: self.layer_id.clone(), - detail: format!( - "decoded BLAKE3 has {} bytes instead of 32", - self.decoded_blake3.len() - ), - }); - } - Ok(self.decoded_byte_length as u64) - } - - fn stored_layer(&self, payload: Vec) -> Result { - let decoded_blake3: [u8; 32] = self.decoded_blake3.as_slice().try_into().map_err(|_| { - StoreError::LayerDirectoryMismatch { - layer_id: self.layer_id.clone(), - detail: "decoded BLAKE3 is not 32 bytes".to_string(), - } - })?; - Ok(StoredLayerPayload { - compression: self.compression.as_str().try_into()?, - stored_byte_length: self.stored_byte_length as u64, - decoded_byte_length: self.decoded_byte_length as u64, - decoded_blake3, - bytes: payload, - }) - } -} - -fn map_directory_facts_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - Ok(DirectoryFacts { - layer_id: row.get(0)?, - source_id: row.get(1)?, - width: row.get(2)?, - height: row.get(3)?, - inner_format: row.get(4)?, - compression: row.get(5)?, - stored_byte_length: row.get(6)?, - decoded_byte_length: row.get(7)?, - decoded_blake3: row.get(8)?, - actual_stored_byte_length: row.get(9)?, - }) -} - -pub(crate) fn load_glyph_layer_from_conn( - conn: &Connection, - layer_id: &font::LayerId, -) -> Result, StoreError> { - let mut directory_stmt = conn.prepare_cached( - " - SELECT l.id, l.source_id, l.width, l.height, - p.inner_format, p.compression, - p.stored_byte_length, p.decoded_byte_length, p.decoded_blake3, - length(p.payload) - FROM glyph_layers AS l - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - WHERE l.id = ?1 - ", - )?; - let facts = directory_stmt - .query_row([layer_id.to_string()], map_directory_facts_row) - .optional()?; - drop(directory_stmt); - let Some(facts) = facts else { - return Ok(None); - }; - facts.validate()?; - - // The length gate is deliberately a separate query so SQLite cannot - // allocate an unbounded Vec before the store applies its limit. - let mut payload_stmt = conn.prepare_cached( - " - SELECT payload - FROM glyph_layer_payloads - WHERE layer_id = ?1 - AND length(payload) = ?2 - AND length(payload) <= ?3 - ", - )?; - let payload: Vec = payload_stmt - .query_row( - params![ - layer_id.to_string(), - facts.actual_stored_byte_length, - MAX_LAYER_PAYLOAD_BYTES - ], - |row| row.get(0), - ) - .optional()? - .ok_or_else(|| StoreError::LayerDirectoryMismatch { - layer_id: layer_id.to_string(), - detail: "payload changed during bounded read".to_string(), - })?; - let decoded = decompress_glyph_layer(facts.layer_id.as_str(), facts.stored_layer(payload)?)?; - let layer = decode_layer(&decoded)?; - validate_directory_facts( - layer_id, - &facts.source_id, - facts.width, - facts.height, - &layer, - )?; - validate_component_index(conn, &layer)?; - Ok(Some(layer)) -} - -pub(crate) fn load_glyph_layers_from_conn( - conn: &Connection, - layer_ids: &[font::LayerId], -) -> Result, StoreError> { - let mut keys = layer_ids - .iter() - .map(ToString::to_string) - .collect::>(); - keys.sort(); - keys.dedup(); - if keys.is_empty() { - return Ok(Vec::new()); - } - - let mut layers = Vec::with_capacity(keys.len()); - for count_batch in keys.chunks(MAX_LAYER_READ_BATCH_COUNT) { - let placeholders = (0..count_batch.len()) - .map(|_| "?") - .collect::>() - .join(","); - let sql = format!( - "SELECT l.id, p.decoded_byte_length - FROM glyph_layers AS l - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - WHERE l.id IN ({placeholders}) - ORDER BY l.id" - ); - let mut stmt = conn.prepare(&sql)?; - let rows = stmt.query_map(rusqlite::params_from_iter(count_batch.iter()), |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) - })?; - let mut decoded_lengths = HashMap::with_capacity(count_batch.len()); - for row in rows { - let (layer_id, decoded_byte_length) = row?; - if !(0..=MAX_LAYER_PAYLOAD_BYTES).contains(&decoded_byte_length) { - return Err(StoreError::LayerPayloadTooLarge { - bytes: u64::try_from(decoded_byte_length).unwrap_or(u64::MAX), - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }); - } - decoded_lengths.insert(layer_id, decoded_byte_length as u64); - } - drop(stmt); - - let mut batch = Vec::new(); - let mut batch_decoded_bytes = 0_u64; - for key in count_batch { - let decoded_byte_length = - decoded_lengths - .get(key) - .copied() - .ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: key.clone(), - })?; - if !batch.is_empty() - && batch_decoded_bytes.saturating_add(decoded_byte_length) - > MAX_LAYER_READ_BATCH_DECODED_BYTES - { - layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); - batch.clear(); - batch_decoded_bytes = 0; - } - batch.push(font::LayerId::from_raw(key.clone())); - batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); - } - if !batch.is_empty() { - layers.extend(load_glyph_layer_batch_from_conn(conn, &batch)?); - } - } - - Ok(layers) -} - -fn load_glyph_layer_batch_from_conn( - conn: &Connection, - layer_ids: &[font::LayerId], -) -> Result, StoreError> { - let mut keys = layer_ids - .iter() - .map(ToString::to_string) - .collect::>(); - keys.sort(); - keys.dedup(); - if keys.is_empty() { - return Ok(Vec::new()); - } - if keys.len() > MAX_LAYER_READ_BATCH_COUNT { - return Err(StoreError::LayerReadBatchTooLarge { - layers: keys.len(), - limit: MAX_LAYER_READ_BATCH_COUNT, - }); - } - - let placeholders = (0..keys.len()).map(|_| "?").collect::>().join(","); - let directory_sql = format!( - " - SELECT l.id, l.source_id, l.width, l.height, - p.inner_format, p.compression, - p.stored_byte_length, p.decoded_byte_length, p.decoded_blake3, - length(p.payload) - FROM glyph_layers AS l - JOIN glyph_layer_payloads AS p ON p.layer_id = l.id - WHERE l.id IN ({placeholders}) - ORDER BY l.id - " - ); - let mut directory_stmt = conn.prepare(&directory_sql)?; - let directory_rows = directory_stmt.query_map( - rusqlite::params_from_iter(keys.iter()), - map_directory_facts_row, - )?; - let mut facts = HashMap::with_capacity(keys.len()); - let mut batch_decoded_bytes = 0_u64; - for row in directory_rows { - let facts_row = row?; - let decoded_byte_length = facts_row.validate()?; - batch_decoded_bytes = batch_decoded_bytes.saturating_add(decoded_byte_length); - facts.insert(facts_row.layer_id.clone(), facts_row); - } - drop(directory_stmt); - - for key in &keys { - if !facts.contains_key(key) { - return Err(StoreError::MissingEntity { - kind: "glyph layer", - id: key.clone(), - }); - } - } - if batch_decoded_bytes > MAX_LAYER_READ_BATCH_DECODED_BYTES { - return Err(StoreError::LayerReadBatchDecodedTooLarge { - bytes: batch_decoded_bytes, - limit: MAX_LAYER_READ_BATCH_DECODED_BYTES, - }); - } - - let payload_sql = format!( - " - SELECT layer_id, payload, length(payload) - FROM glyph_layer_payloads - WHERE layer_id IN ({placeholders}) - AND length(payload) <= ? - ORDER BY layer_id - " - ); - let mut payload_params = keys - .iter() - .cloned() - .map(rusqlite::types::Value::Text) - .collect::>(); - payload_params.push(rusqlite::types::Value::Integer(MAX_LAYER_PAYLOAD_BYTES)); - let mut payload_stmt = conn.prepare(&payload_sql)?; - let payload_rows = - payload_stmt.query_map(rusqlite::params_from_iter(payload_params), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, Vec>(1)?, - row.get::<_, i64>(2)?, - )) - })?; - let mut payloads = Vec::with_capacity(keys.len()); - for row in payload_rows { - let (layer_id, payload, actual_len) = row?; - let facts = facts - .get(&layer_id) - .ok_or_else(|| StoreError::LayerDirectoryMismatch { - layer_id: layer_id.clone(), - detail: "payload has no directory row".to_string(), - })?; - if actual_len != facts.actual_stored_byte_length - || payload.len() as i64 != facts.actual_stored_byte_length - { - return Err(StoreError::LayerDirectoryMismatch { - layer_id, - detail: "payload changed during bounded batch read".to_string(), - }); - } - payloads.push((layer_id, payload)); - } - drop(payload_stmt); - - let decoded = payloads - .into_par_iter() - .map(|(layer_id, payload)| { - let facts = facts - .get(&layer_id) - .ok_or_else(|| StoreError::LayerDirectoryMismatch { - layer_id: layer_id.clone(), - detail: "payload has no directory row".to_string(), - })?; - let decoded = decompress_glyph_layer(layer_id.as_str(), facts.stored_layer(payload)?)?; - let layer = decode_layer(&decoded)?; - let expected_id = font::LayerId::from_raw(layer_id.clone()); - validate_directory_facts( - &expected_id, - &facts.source_id, - facts.width, - facts.height, - &layer, - )?; - Ok((layer_id, layer)) - }) - .collect::, StoreError>>()?; - let loaded = decoded - .iter() - .map(|(layer_id, _)| layer_id.clone()) - .collect::>(); - let layers = decoded - .into_iter() - .map(|(_, layer)| layer) - .collect::>(); - - for key in &keys { - if !loaded.contains(key) { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: key.clone(), - detail: "payload changed during bounded batch read".to_string(), - }); - } - } - - let component_sql = format!( - " - SELECT layer_id, id, base_glyph_id, order_index - FROM glyph_components - WHERE layer_id IN ({placeholders}) - ORDER BY layer_id, order_index, id - " - ); - let mut component_stmt = conn.prepare(&component_sql)?; - let component_rows = - component_stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - )) - })?; - let mut components: HashMap> = HashMap::new(); - for row in component_rows { - let (layer_id, component_id, base_glyph_id, order_index) = row?; - components - .entry(layer_id) - .or_default() - .push((component_id, base_glyph_id, order_index)); - } - drop(component_stmt); - - for layer in &layers { - let indexed = components.remove(layer.id().as_str()).unwrap_or_default(); - validate_component_rows(layer, &indexed)?; - } - Ok(layers) -} - -pub(crate) fn write_layer_in_tx( - tx: &Transaction<'_>, - glyph_id: &font::GlyphId, - layer: &font::GlyphLayer, -) -> Result<(), StoreError> { - let encoded = encode_layer(layer)?; - let stored = compress_glyph_layer(&encoded)?; - store_stored_layer_in_tx(tx, glyph_id, layer, &stored, WriteMode::Upsert) -} - -pub(crate) fn store_stored_layer_in_tx( - tx: &Transaction<'_>, - glyph_id: &font::GlyphId, - layer: &font::GlyphLayer, - stored: &StoredLayerPayload, - mode: WriteMode, -) -> Result<(), StoreError> { - let stored_byte_length = encoded_len(stored.stored_byte_length)?; - let decoded_byte_length = encoded_len(stored.decoded_byte_length)?; - let layer_sql = match mode { - WriteMode::Insert => { - "INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, ?4, ?5)" - } - WriteMode::Upsert => { - " - INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) - VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(id) DO UPDATE SET - glyph_id = excluded.glyph_id, - source_id = excluded.source_id, - width = excluded.width, - height = excluded.height - " - } - }; - tx.prepare_cached(layer_sql)?.execute(params![ - layer.id().to_string(), - glyph_id.to_string(), - layer.source_id().to_string(), - layer.width(), - layer.height(), - ])?; - - let payload_sql = match mode { - WriteMode::Insert => { - "INSERT INTO glyph_layer_payloads ( - layer_id, inner_format, compression, payload, - stored_byte_length, decoded_byte_length, decoded_blake3 - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" - } - WriteMode::Upsert => { - " - INSERT INTO glyph_layer_payloads ( - layer_id, inner_format, compression, payload, - stored_byte_length, decoded_byte_length, decoded_blake3 - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(layer_id) DO UPDATE SET - inner_format = excluded.inner_format, - compression = excluded.compression, - payload = excluded.payload, - stored_byte_length = excluded.stored_byte_length, - decoded_byte_length = excluded.decoded_byte_length, - decoded_blake3 = excluded.decoded_blake3 - " - } - }; - tx.prepare_cached(payload_sql)?.execute(params![ - layer.id().to_string(), - GLYPH_LAYER_FORMAT, - stored.compression.as_str(), - stored.bytes.as_slice(), - stored_byte_length, - decoded_byte_length, - stored.decoded_blake3.as_slice(), - ])?; - write_component_index(tx, layer, mode) -} - -fn encoded_len(bytes: u64) -> Result { - i64::try_from(bytes).map_err(|_| StoreError::LayerPayloadTooLarge { - bytes, - limit: MAX_LAYER_PAYLOAD_BYTES as u64, - }) -} - -pub(crate) fn rewrite_layer_in_tx( - tx: &Transaction<'_>, - layer: &font::GlyphLayer, -) -> Result<(), StoreError> { - let owner = layer_owner(tx, &layer.id())?.ok_or_else(|| StoreError::MissingEntity { - kind: "glyph layer", - id: layer.id().to_string(), - })?; - write_layer_in_tx(tx, &owner, layer) -} - -pub(crate) fn create_empty_layer_in_tx( - tx: &Transaction<'_>, - glyph_id: &font::GlyphId, - layer_id: font::LayerId, - source_id: font::SourceId, - width: f64, - height: Option, -) -> Result<(), StoreError> { - let mut layer = font::GlyphLayer::with_width(layer_id, source_id, width); - layer.set_height(height); - write_layer_in_tx(tx, glyph_id, &layer) -} - -fn write_component_index( - tx: &Transaction<'_>, - layer: &font::GlyphLayer, - mode: WriteMode, -) -> Result<(), StoreError> { - if mode == WriteMode::Upsert { - tx.prepare_cached("DELETE FROM glyph_components WHERE layer_id = ?1")? - .execute([layer.id().to_string()])?; - } - for (order_index, component) in layer.components_iter().enumerate() { - tx.prepare_cached( - " - INSERT INTO glyph_components ( - id, layer_id, base_glyph_id, order_index - ) VALUES (?1, ?2, ?3, ?4) - ", - )? - .execute(params![ - component.id().to_string(), - layer.id().to_string(), - component.base_glyph_id().to_string(), - order_index as i64, - ])?; - } - Ok(()) -} - -fn validate_directory_facts( - expected_id: &font::LayerId, - expected_source_id: &str, - expected_width: f64, - expected_height: Option, - layer: &font::GlyphLayer, -) -> Result<(), StoreError> { - let detail = if layer.id() != *expected_id { - Some(format!("payload id is {}", layer.id())) - } else if layer.source_id().as_str() != expected_source_id { - Some(format!("payload source id is {}", layer.source_id())) - } else if layer.width().to_bits() != expected_width.to_bits() { - Some("payload width differs from directory width".to_string()) - } else if !same_optional_f64(layer.height(), expected_height) { - Some("payload height differs from directory height".to_string()) - } else { - None - }; - if let Some(detail) = detail { - return Err(StoreError::LayerDirectoryMismatch { - layer_id: expected_id.to_string(), - detail, - }); - } - Ok(()) -} - -fn validate_component_index(conn: &Connection, layer: &font::GlyphLayer) -> Result<(), StoreError> { - let mut stmt = conn.prepare_cached( - " - SELECT id, base_glyph_id, order_index - FROM glyph_components - WHERE layer_id = ?1 - ORDER BY order_index, id - ", - )?; - let indexed = stmt - .query_map([layer.id().to_string()], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, i64>(2)?, - )) - })? - .collect::, _>>()?; - validate_component_rows(layer, &indexed) -} - -fn validate_component_rows( - layer: &font::GlyphLayer, - indexed: &[(String, String, i64)], -) -> Result<(), StoreError> { - let authored: Vec<_> = layer - .components_iter() - .enumerate() - .map(|(order_index, component)| { - ( - component.id().to_string(), - component.base_glyph_id().to_string(), - order_index as i64, - ) - }) - .collect(); - if indexed != authored { - return Err(StoreError::StaleLayerReferenceIndex { - layer_id: layer.id().to_string(), - }); - } - Ok(()) -} - -fn layer_owner( - conn: &Connection, - layer_id: &font::LayerId, -) -> Result, StoreError> { - conn.query_row( - "SELECT glyph_id FROM glyph_layers WHERE id = ?1", - [layer_id.to_string()], - |row| Ok(font::GlyphId::from_raw(row.get::<_, String>(0)?)), - ) - .optional() - .map_err(Into::into) -} - -fn map_directory_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let stored_byte_length = directory_byte_length(row, 6)?; - let decoded_byte_length = directory_byte_length(row, 7)?; - Ok(GlyphLayerDirectoryEntry { - layer_id: font::LayerId::from_raw(row.get::<_, String>(0)?), - glyph_id: font::GlyphId::from_raw(row.get::<_, String>(1)?), - source_id: font::SourceId::from_raw(row.get::<_, String>(2)?), - name: row.get::<_, Option>(3)?.map(font::GlyphName::from), - width: row.get(4)?, - height: row.get(5)?, - stored_byte_length, - decoded_byte_length, - }) -} - -fn directory_byte_length(row: &rusqlite::Row<'_>, index: usize) -> rusqlite::Result { - let byte_length = row.get::<_, i64>(index)?; - u64::try_from(byte_length).map_err(|error| { - rusqlite::Error::FromSqlConversionFailure( - index, - rusqlite::types::Type::Integer, - Box::new(error), - ) - }) -} - -fn same_optional_f64(left: Option, right: Option) -> bool { - match (left, right) { - (Some(left), Some(right)) => left.to_bits() == right.to_bits(), - (None, None) => true, - _ => false, - } -} - -#[cfg(test)] -mod tests { - use std::time::{Duration, Instant}; - - use shift_font as font; - - use super::*; - use crate::{ShiftStore, WorkspaceState}; - - fn populated_store() -> (ShiftStore, font::Font) { - let mut store = ShiftStore::open_memory_for_test().unwrap(); - let font = font::test_support::sample_font(); - store.replace_font_state(&font).unwrap(); - store - .set_workspace_state(WorkspaceState::untitled(None)) - .unwrap(); - (store, font) - } - - #[test] - fn sqlite_payload_preserves_canonical_bytes() { - let font = font::test_support::sample_font(); - let layer = font - .glyph_by_name("A") - .unwrap() - .layers() - .values() - .next() - .unwrap(); - let encoded = encode_layer(layer).unwrap(); - let glyph_id = font::GlyphId::from_raw("storage_golden"); - let glyph_name = font::GlyphName::from("storageGolden"); - let mut store = ShiftStore::open_memory_for_test().unwrap(); - store - .conn - .execute( - "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'Golden', 'master', 0)", - [layer.source_id().to_string()], - ) - .unwrap(); - store - .conn - .execute( - "INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, 0)", - params![glyph_id.to_string(), glyph_name.as_str()], - ) - .unwrap(); - - let tx = store.conn.transaction().unwrap(); - write_layer_in_tx(&tx, &glyph_id, layer).unwrap(); - tx.commit().unwrap(); - - let stored = store - .conn - .query_row( - "SELECT compression, payload, stored_byte_length, - decoded_byte_length, decoded_blake3 - FROM glyph_layer_payloads WHERE layer_id = ?1", - [layer.id().to_string()], - |row| { - Ok(StoredLayerPayload { - compression: match row.get::<_, String>(0)?.as_str() { - "zstd.v1" => crate::types::LayerPayloadCompression::ZstandardV1, - _ => crate::types::LayerPayloadCompression::None, - }, - bytes: row.get(1)?, - stored_byte_length: row.get::<_, i64>(2)? as u64, - decoded_byte_length: row.get::<_, i64>(3)? as u64, - decoded_blake3: row.get::<_, Vec>(4)?.try_into().unwrap(), - }) - }, - ) - .unwrap(); - assert_eq!( - stored.compression, - crate::types::LayerPayloadCompression::ZstandardV1 - ); - assert!(stored.stored_byte_length < stored.decoded_byte_length); - assert_eq!( - decompress_glyph_layer(layer.id().as_str(), stored).unwrap(), - encoded - ); - let decoded = store.load_glyph_layer(&layer.id()).unwrap().unwrap(); - assert_eq!(encode_layer(&decoded).unwrap(), encoded); - } - - #[test] - fn bounded_batch_load_matches_complete_font_layers() { - let (store, font) = populated_store(); - let mut layer_ids = font - .glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()) - .collect::>(); - layer_ids.push(layer_ids[0].clone()); - - let layers = store.load_glyph_layers(&layer_ids).unwrap(); - - assert_eq!(layers.len(), layer_ids.len() - 1); - for layer in layers { - assert_eq!(font.layer(layer.id()).unwrap(), &layer); - } - } - - #[test] - fn requested_layers_load_across_multiple_count_bounded_batches() { - let mut font = font::Font::new(); - let source_id = font.default_source_id().unwrap(); - let mut layer_ids = Vec::new(); - for index in 0..=MAX_LAYER_READ_BATCH_COUNT { - let layer = font::GlyphLayer::with_width( - font::LayerId::from_raw(format!("layer_{index:04}")), - source_id.clone(), - index as f64, - ); - layer_ids.push(layer.id()); - let mut glyph = font::Glyph::new(format!("glyph_{index:04}")); - glyph.set_layer(layer); - font.insert_glyph(glyph).unwrap(); - } - let mut store = ShiftStore::open_memory_for_test().unwrap(); - store.replace_font_state(&font).unwrap(); - - let layers = store.load_glyph_layers(&layer_ids).unwrap(); - - assert_eq!(layers.len(), layer_ids.len()); - assert_eq!(layers.first().unwrap().id(), layer_ids[0]); - assert_eq!(layers.last().unwrap().id(), layer_ids[512]); - } - - #[test] - fn one_internal_batch_rejects_excessive_layer_counts_before_sql() { - let store = ShiftStore::open_memory_for_test().unwrap(); - let layer_ids = (0..=MAX_LAYER_READ_BATCH_COUNT) - .map(|index| font::LayerId::from_raw(format!("layer_{index}"))) - .collect::>(); - - assert!(matches!( - load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), - Err(StoreError::LayerReadBatchTooLarge { .. }) - )); - } - - #[test] - fn batch_bounds_declared_decoded_bytes_before_fetching_payloads() { - let (store, font) = populated_store(); - let layer_ids = font - .glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()) - .take(2) - .collect::>(); - let decoded_byte_length = MAX_LAYER_READ_BATCH_DECODED_BYTES / 2 + 1; - for layer_id in &layer_ids { - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET decoded_byte_length = ?1 - WHERE layer_id = ?2", - params![decoded_byte_length as i64, layer_id.to_string()], - ) - .unwrap(); - } - - assert!(matches!( - load_glyph_layer_batch_from_conn(&store.conn, &layer_ids), - Err(StoreError::LayerReadBatchDecodedTooLarge { .. }) - )); - } - - #[test] - fn mixed_compressed_and_uncompressed_layers_load_in_stable_order() { - let (store, font) = populated_store(); - let mut layer_ids = font - .glyphs() - .flat_map(|glyph| glyph.layers().keys().cloned()) - .take(2) - .collect::>(); - layer_ids.sort_by(|left, right| left.as_str().cmp(right.as_str())); - let uncompressed_layer = font.layer(layer_ids[0].clone()).unwrap(); - let encoded = encode_layer(uncompressed_layer).unwrap(); - let decoded_blake3 = blake3::hash(&encoded); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET compression = 'none', payload = ?1, - stored_byte_length = ?2, decoded_byte_length = ?2, - decoded_blake3 = ?3 - WHERE layer_id = ?4", - params![ - encoded.as_slice(), - encoded.len() as i64, - decoded_blake3.as_bytes().as_slice(), - layer_ids[0].to_string(), - ], - ) - .unwrap(); - - let loaded = store.load_glyph_layers(&layer_ids).unwrap(); - - assert_eq!(loaded.len(), 2); - assert_eq!(loaded[0].id(), layer_ids[0]); - assert_eq!(loaded[1].id(), layer_ids[1]); - for layer in loaded { - assert_eq!(font.layer(layer.id()).unwrap(), &layer); - } - } - - #[test] - fn declared_stored_length_mismatch_is_rejected_before_decode() { - let (store, font) = populated_store(); - let layer_id = font - .glyphs() - .next() - .unwrap() - .layers() - .keys() - .next() - .unwrap(); - store - .conn - .pragma_update(None, "ignore_check_constraints", "ON") - .unwrap(); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET stored_byte_length = stored_byte_length + 1 - WHERE layer_id = ?1", - [layer_id.to_string()], - ) - .unwrap(); - store - .conn - .pragma_update(None, "ignore_check_constraints", "OFF") - .unwrap(); - - assert!(matches!( - store.load_glyph_layer(layer_id), - Err(StoreError::LayerDirectoryMismatch { detail, .. }) - if detail.contains("declared stored byte length") - )); - assert!(matches!( - store.load_glyph_layers(std::slice::from_ref(layer_id)), - Err(StoreError::LayerDirectoryMismatch { detail, .. }) - if detail.contains("declared stored byte length") - )); - } - - #[test] - fn layer_rewrite_transitions_from_uncompressed_to_zstd_consistently() { - let (mut store, font) = populated_store(); - let layer = font - .glyphs() - .next() - .unwrap() - .layers() - .values() - .next() - .unwrap(); - let mut compressible_layer = layer.as_ref().clone(); - compressible_layer.lib_mut().set( - "com.shift.compression-fixture".to_string(), - font::LibValue::Data(vec![0; 8 * 1024]), - ); - let encoded = encode_layer(&compressible_layer).unwrap(); - let uncompressed = StoredLayerPayload { - compression: crate::types::LayerPayloadCompression::None, - bytes: encoded.clone(), - stored_byte_length: encoded.len() as u64, - decoded_byte_length: encoded.len() as u64, - decoded_blake3: *blake3::hash(&encoded).as_bytes(), - }; - let glyph_id = font.glyph_id_by_layer(layer.id()).unwrap(); - let tx = store.conn.transaction().unwrap(); - store_stored_layer_in_tx( - &tx, - &glyph_id, - &compressible_layer, - &uncompressed, - WriteMode::Upsert, - ) - .unwrap(); - tx.commit().unwrap(); - assert_eq!( - store - .conn - .query_row( - "SELECT compression FROM glyph_layer_payloads WHERE layer_id = ?1", - [layer.id().to_string()], - |row| row.get::<_, String>(0), - ) - .unwrap(), - "none" - ); - - store.replace_glyph_layer(&compressible_layer).unwrap(); - - let (compression, stored, decoded, hash_length, payload_length) = store - .conn - .query_row( - "SELECT compression, stored_byte_length, decoded_byte_length, - length(decoded_blake3), length(payload) - FROM glyph_layer_payloads WHERE layer_id = ?1", - [layer.id().to_string()], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - )) - }, - ) - .unwrap(); - assert_eq!(compression, "zstd.v1"); - assert_eq!(stored, payload_length); - assert!(stored < decoded); - assert_eq!(hash_length, 32); - assert_eq!( - store.load_glyph_layer(&layer.id()).unwrap().unwrap(), - compressible_layer - ); - } - - #[test] - fn false_decoded_length_and_hash_are_rejected() { - let (store, font) = populated_store(); - let layer_id = font - .glyphs() - .next() - .unwrap() - .layers() - .keys() - .next() - .unwrap(); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET decoded_byte_length = decoded_byte_length + 1 - WHERE layer_id = ?1", - [layer_id.to_string()], - ) - .unwrap(); - assert!(matches!( - store.load_glyph_layer(layer_id), - Err(StoreError::LayerDirectoryMismatch { detail, .. }) - if detail.contains("decoded byte length") - )); - - let encoded = encode_layer(font.layer(layer_id.clone()).unwrap()).unwrap(); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET decoded_byte_length = ?1, decoded_blake3 = zeroblob(32) - WHERE layer_id = ?2", - params![encoded.len() as i64, layer_id.to_string()], - ) - .unwrap(); - assert!(matches!( - store.load_glyph_layer(layer_id), - Err(StoreError::LayerDirectoryMismatch { detail, .. }) - if detail.contains("decoded BLAKE3 mismatch") - )); - } - - #[test] - fn directory_open_never_reads_malformed_payloads() { - let (store, font) = populated_store(); - let layer_id = font - .glyphs() - .next() - .unwrap() - .layers() - .keys() - .next() - .unwrap(); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET payload = X'00', stored_byte_length = 1 - WHERE layer_id = ?1", - [layer_id.to_string()], - ) - .unwrap(); - - let directory = store.load_font_directory().unwrap(); - assert_eq!(directory.glyph_count(), font.glyph_count()); - assert!(directory.layer(layer_id.clone()).unwrap().is_empty()); - assert!(store.load_glyph_layer(layer_id).is_err()); - assert!( - store - .load_glyph_layers(std::slice::from_ref(layer_id)) - .is_err() - ); - } - - #[test] - fn directory_retains_layer_when_payload_row_is_missing() { - let (store, font) = populated_store(); - let layer_id = font - .glyphs() - .next() - .unwrap() - .layers() - .keys() - .next() - .unwrap(); - store - .conn - .execute( - "DELETE FROM glyph_layer_payloads WHERE layer_id = ?1", - [layer_id.to_string()], - ) - .unwrap(); - - let directory = store.load_font_directory().unwrap(); - - assert!(directory.layer(layer_id.clone()).is_some()); - assert!(store.load_font_state().is_err()); - } - - #[test] - fn stale_reference_index_is_rejected() { - let (store, font) = populated_store(); - let layer = font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .find(|layer| !layer.components().is_empty()) - .unwrap(); - store - .conn - .execute( - "DELETE FROM glyph_components WHERE layer_id = ?1", - [layer.id().to_string()], - ) - .unwrap(); - - assert!(matches!( - store.load_glyph_layer(&layer.id()), - Err(StoreError::StaleLayerReferenceIndex { .. }) - )); - assert!(matches!( - store.load_glyph_layers(&[layer.id()]), - Err(StoreError::StaleLayerReferenceIndex { .. }) - )); - } - - #[test] - fn index_constraint_failure_rolls_back_payload_directory_and_revision() { - let (mut store, font) = populated_store(); - let indexed_layer = font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .find(|layer| !layer.components().is_empty()) - .unwrap(); - let duplicate = indexed_layer.components_iter().next().unwrap(); - let mut target = font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .find(|layer| layer.id() != indexed_layer.id() && layer.components().is_empty()) - .unwrap() - .as_ref() - .clone(); - target.add_component(font::Component::with_id( - duplicate.id(), - duplicate.base_glyph_id(), - duplicate.base_glyph_name().clone(), - *duplicate.transform(), - )); - - let before_payload: Vec = store - .conn - .query_row( - "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", - [target.id().to_string()], - |row| row.get(0), - ) - .unwrap(); - let before_revision = store.workspace_state().unwrap().unwrap().revision; - - assert!(store.replace_glyph_layer(&target).is_err()); - - let after_payload: Vec = store - .conn - .query_row( - "SELECT payload FROM glyph_layer_payloads WHERE layer_id = ?1", - [target.id().to_string()], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(after_payload, before_payload); - assert_eq!( - store.workspace_state().unwrap().unwrap().revision, - before_revision - ); - } - - #[test] - fn replacement_updates_payload_directory_references_and_revision_atomically() { - let (mut store, font) = populated_store(); - let mut layer = font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .find(|layer| !layer.components().is_empty()) - .unwrap() - .as_ref() - .clone(); - let owner = font.glyph_id_by_layer(layer.id()).unwrap(); - layer.set_width(layer.width() + 23.0); - layer.clear_components(); - - store.replace_glyph_layer(&layer).unwrap(); - - assert_eq!( - store.load_glyph_layer(&layer.id()).unwrap(), - Some(layer.clone()) - ); - let directory = store - .list_glyph_layer_directory() - .unwrap() - .into_iter() - .find(|entry| entry.layer_id == layer.id()) - .unwrap(); - assert_eq!(directory.width.to_bits(), layer.width().to_bits()); - assert!( - store - .referenced_glyph_ids_for_glyph(&owner) - .unwrap() - .is_empty() - ); - assert_eq!(store.workspace_state().unwrap().unwrap().revision, 1); - } - - #[test] - fn post_font_change_set_rewrites_touched_layer_without_decoding_old_payload() { - let (mut store, mut post_font) = populated_store(); - let mut layer = post_font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .next() - .unwrap() - .as_ref() - .clone(); - layer.set_width(layer.width() + 41.0); - post_font.replace_glyph_layers(vec![layer.clone()]).unwrap(); - store - .conn - .execute( - "UPDATE glyph_layer_payloads - SET payload = X'00', stored_byte_length = 1 - WHERE layer_id = ?1", - [layer.id().to_string()], - ) - .unwrap(); - let changes = - font::FontChangeSet::new(vec![font::FontChange::layer_metrics_changed(&layer)]); - - store - .apply_change_set_with_font(&changes, &post_font) - .unwrap(); - - assert_eq!(store.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); - } - - #[test] - fn abandoned_transaction_preserves_last_committed_layer() { - let temp = tempfile::tempdir().unwrap(); - let path = temp.path().join("working.sqlite"); - let font = font::test_support::sample_font(); - let layer = font - .glyphs() - .flat_map(|glyph| glyph.layers().values()) - .next() - .unwrap() - .as_ref() - .clone(); - let mut store = ShiftStore::open(&path).unwrap(); - store.replace_font_state(&font).unwrap(); - let mut uncommitted = layer.clone(); - uncommitted.set_width(layer.width() + 999.0); - { - let tx = store.conn.transaction().unwrap(); - rewrite_layer_in_tx(&tx, &uncommitted).unwrap(); - // Dropping an open transaction models recovery from a process - // that exits before COMMIT reaches the WAL. - } - drop(store); - - let reopened = ShiftStore::open(&path).unwrap(); - assert_eq!(reopened.load_glyph_layer(&layer.id()).unwrap(), Some(layer)); - } - - #[test] - fn abandoned_font_stream_rolls_back_header_and_glyphs() { - let (mut store, font) = populated_store(); - let before = store.list_glyph_layer_directory().unwrap(); - { - let mut writer = store.begin_import(&font).unwrap(); - let glyph = font.glyphs().next().unwrap(); - writer - .write_glyph_batch(std::slice::from_ref(glyph)) - .unwrap(); - } - - assert_eq!(store.list_glyph_layer_directory().unwrap(), before); - } - - #[test] - fn cjk_scale_directory_open_is_payload_independent() { - const GLYPH_COUNT: usize = 65_536; - let mut store = ShiftStore::open_memory_for_test().unwrap(); - let source_id = font::SourceId::from_raw("cjk").to_string(); - store - .conn - .execute( - "INSERT INTO sources (id, name, kind, order_index) VALUES (?1, 'CJK', 'master', 0)", - [&source_id], - ) - .unwrap(); - let tx = store.conn.transaction().unwrap(); - { - let mut glyph_stmt = tx - .prepare("INSERT INTO glyphs (id, name, order_index) VALUES (?1, ?2, ?3)") - .unwrap(); - let mut layer_stmt = tx - .prepare("INSERT INTO glyph_layers (id, glyph_id, source_id, width, height) VALUES (?1, ?2, ?3, 1000, NULL)") - .unwrap(); - let mut payload_stmt = tx - .prepare( - "INSERT INTO glyph_layer_payloads ( - layer_id, inner_format, compression, payload, - stored_byte_length, decoded_byte_length, decoded_blake3 - ) VALUES (?1, ?2, 'none', X'00', 1, 1, zeroblob(32))", - ) - .unwrap(); - for index in 0..GLYPH_COUNT { - let glyph_id = font::GlyphId::from_raw(format!("cjk{index}")).to_string(); - let layer_id = font::LayerId::from_raw(format!("cjk{index}")).to_string(); - let name = format!("uni{index:05X}"); - glyph_stmt - .execute(params![glyph_id, name, index as i64]) - .unwrap(); - layer_stmt - .execute(params![layer_id, glyph_id, source_id]) - .unwrap(); - payload_stmt - .execute(params![layer_id, GLYPH_LAYER_FORMAT]) - .unwrap(); - } - } - tx.commit().unwrap(); - - let started = Instant::now(); - let directory = store.load_font_directory().unwrap(); - let elapsed = started.elapsed(); - - assert_eq!(directory.glyph_count(), GLYPH_COUNT); - assert_eq!( - directory - .glyphs() - .map(|glyph| glyph.layers().len()) - .sum::(), - GLYPH_COUNT - ); - assert!( - elapsed < Duration::from_secs(15), - "directory open took {elapsed:?}" - ); - } -} diff --git a/crates/shift-store/src/types.rs b/crates/shift-store/src/types.rs index 6796fc09..76945170 100644 --- a/crates/shift-store/src/types.rs +++ b/crates/shift-store/src/types.rs @@ -2,7 +2,7 @@ use std::time::Duration; use shift_font::{Glyph, LayerId}; -use crate::StoreError; +use crate::layer::StoredLayerPayload; pub(crate) type EncodedGlyphLayers = Vec)>>; @@ -29,41 +29,6 @@ impl GlyphWriteBatch { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum LayerPayloadCompression { - None, - ZstandardV1, -} - -impl LayerPayloadCompression { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::None => "none", - Self::ZstandardV1 => "zstd.v1", - } - } -} - -impl TryFrom<&str> for LayerPayloadCompression { - type Error = StoreError; - - fn try_from(value: &str) -> Result { - match value { - "none" => Ok(Self::None), - "zstd.v1" => Ok(Self::ZstandardV1), - value => Err(StoreError::UnsupportedLayerCompression(value.to_owned())), - } - } -} - -pub(crate) struct StoredLayerPayload { - pub(crate) compression: LayerPayloadCompression, - pub(crate) bytes: Vec, - pub(crate) stored_byte_length: u64, - pub(crate) decoded_byte_length: u64, - pub(crate) decoded_blake3: [u8; 32], -} - pub(crate) struct PackedGlyph { pub(crate) glyph: Glyph, pub(crate) layers: Vec<(LayerId, StoredLayerPayload)>, From e3107e75dc94cfee4fc17993ea3ce6a5a2bc04a6 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 31 Jul 2026 17:19:48 -0400 Subject: [PATCH 26/29] Restore complete Slug atlas residency --- apps/desktop/e2e/gpu.spec.ts | 303 +++++------------- .../components/home/GlyphCatalogController.ts | 174 +++------- .../graphics/backends/ResidentGlyphLayer.ts | 98 ++++-- .../renderer/src/lib/graphics/docs/DOCS.md | 12 +- .../src/renderer/src/lib/model/Font.ts | 2 +- .../src/renderer/src/lib/model/FontStore.ts | 2 +- .../src/renderer/src/lib/slug/SlugRenderer.ts | 2 +- crates/shift-bridge/__test__/index.spec.mjs | 33 ++ crates/shift-bridge/docs/DOCS.md | 4 +- crates/shift-bridge/src/bridge.rs | 5 +- crates/shift-slug/docs/DOCS.md | 4 +- crates/shift-slug/src/resident.rs | 4 +- 12 files changed, 256 insertions(+), 387 deletions(-) diff --git a/apps/desktop/e2e/gpu.spec.ts b/apps/desktop/e2e/gpu.spec.ts index e5c9b892..9c921b95 100644 --- a/apps/desktop/e2e/gpu.spec.ts +++ b/apps/desktop/e2e/gpu.spec.ts @@ -28,6 +28,7 @@ test.describe("Resident catalog GPU", () => { expect(initialSize.width).toBeGreaterThan(1); expect(initialSize.height).toBeGreaterThan(1); await trackSlugFrameSubmits(page); + await trackSlugAtlasLoads(page); const viewportBox = await scrollViewport.boundingBox(); expect(viewportBox).not.toBeNull(); if (!viewportBox) throw new Error("Expected catalog viewport bounds"); @@ -85,10 +86,19 @@ test.describe("Resident catalog GPU", () => { glyphCanvas.evaluate((canvas) => ({ width: canvas.width, height: canvas.height })), ) .toEqual(initialSize); + expect( + await page.evaluate(() => document.documentElement.dataset.slugCompleteAtlasPrepares), + ).toBe("0"); + expect(await page.evaluate(() => document.documentElement.dataset.slugPatchRootCounts)).toBe( + "[]", + ); expect(errors).toEqual([]); }); - test("prioritizes the final viewport while scrubbing the catalog", async ({ page }) => { + test("keeps the complete atlas painted while scrolling downward and upward", async ({ + electronApp, + page, + }) => { const errors: string[] = []; page.on("console", (message) => { if (message.type() === "error" && RESIDENT_GPU_ERROR.test(message.text())) { @@ -97,192 +107,52 @@ test.describe("Resident catalog GPU", () => { }); await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); - await page.waitForFunction(() => (window.shift?.font.glyphRecords().length ?? 0) > 0); - await trackSlugPageLoads(page); const scrollViewport = page.getByLabel("Glyph catalog"); - await expect(scrollViewport).toBeVisible(); - const scrollable = await scrollViewport.evaluate( - (element) => element.scrollHeight > element.clientHeight, - ); - test.skip(!scrollable, "Catalog needs more than one viewport"); - - const scrubStarted = performance.now(); - await scrollViewport.evaluate(async (element) => { - const maximum = element.scrollHeight - element.clientHeight; - for (let step = 1; step <= 12; step += 1) { - element.scrollTop = (maximum * step) / 12; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - for (let step = 11; step >= 0; step -= 1) { - element.scrollTop = (maximum * step) / 12; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - }); - - const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); - await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); - const scrubDuration = performance.now() - scrubStarted; - const pageLoads = await page.evaluate( - () => JSON.parse(document.documentElement.dataset.slugPageRootCounts ?? "[]") as number[], - ); - console.log( - `Resident catalog scrub recovered in ${scrubDuration.toFixed(0)}ms after ${pageLoads.length} page loads`, - ); - expect(pageLoads.length).toBeLessThanOrEqual(1); - - const finalFrame = await scrollViewport.locator("..").screenshot(); - const visibility = await glyphCanvas.evaluate((canvas) => { - const previous = canvas.style.visibility; - canvas.style.visibility = "hidden"; - return previous; - }); - const frameWithoutGlyphs = await scrollViewport.locator("..").screenshot(); - await glyphCanvas.evaluate((canvas, previous) => { - canvas.style.visibility = previous; - }, visibility); - expect(finalFrame.equals(frameWithoutGlyphs)).toBe(false); - expect(errors).toEqual([]); - }); - - test("keeps the fully resident catalog painted while thumb scrubbing", async ({ - electronApp, - page, - }) => { - test.setTimeout(180_000); - await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); - await page.waitForFunction(() => Boolean(window.shift?.font.editCoordinator)); - const glyphCount = await page.evaluate(() => window.shift?.font.glyphRecords().length ?? 0); - test.skip(glyphCount < 300, "Catalog needs a background atlas page"); - - await page.evaluate(() => { - const coordinator = window.shift?.font.editCoordinator; - if (!coordinator) throw new Error("Expected workspace edit coordinator"); - - const originalPrepare = coordinator.prepareSlugAtlasPage.bind(coordinator); - const originalStream = coordinator.streamSlugAtlasPage.bind(coordinator); - let blocked = false; - document.documentElement.dataset.slugPageRootCounts = "[]"; - document.documentElement.dataset.slugPageStreams = "0"; - document.documentElement.dataset.slugBackgroundBlocked = "false"; - coordinator.prepareSlugAtlasPage = async (glyphIds, alignment) => { - const counts = JSON.parse( - document.documentElement.dataset.slugPageRootCounts ?? "[]", - ) as number[]; - counts.push(glyphIds.length); - document.documentElement.dataset.slugPageRootCounts = JSON.stringify(counts); - const descriptor = await originalPrepare(glyphIds, alignment); - if (!blocked && glyphIds.length >= 300) { - blocked = true; - document.documentElement.dataset.slugBackgroundBlocked = "true"; - await new Promise((resolve) => { - window.addEventListener("shift:e2e-release-slug-background", () => resolve(), { - once: true, - }); - }); - } - return descriptor; - }; - coordinator.streamSlugAtlasPage = async (generation, maximumLength, write) => { - document.documentElement.dataset.slugPageStreams = String( - Number(document.documentElement.dataset.slugPageStreams) + 1, - ); - return originalStream(generation, maximumLength, write); - }; - }); - - const scrollViewport = page.getByLabel("Glyph catalog"); - const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); - await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugBackgroundBlocked)) - .toBe("true"); - const catalogSurface = scrollViewport.locator(".."); - const initialViewport = await catalogSurface.screenshot(); - const initialVisibility = await glyphCanvas.evaluate((canvas) => { - const previous = canvas.style.visibility; - canvas.style.visibility = "hidden"; - return previous; - }); - const initialViewportWithoutGlyphs = await catalogSurface.screenshot(); - await glyphCanvas.evaluate((canvas, previous) => { - canvas.style.visibility = previous; - }, initialVisibility); - expect(initialViewport.equals(initialViewportWithoutGlyphs)).toBe(false); + const glyphCanvas = catalogSurface.locator("canvas").first(); + await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); + await expect(glyphCanvas).toHaveAttribute("data-fully-resident", "true"); - await scrollViewport.evaluate((element) => { - element.scrollTop = element.scrollHeight - element.clientHeight; - }); - await expect(glyphCanvas).toBeHidden(); - await page.evaluate(async () => { - const font = window.shift?.font; - const source = font?.sources[0]; - if (!font || !source) throw new Error("Expected a source for version invalidation"); - await font.updateSource({ ...source, name: `${source.name} E2E` }); - window.dispatchEvent(new Event("shift:e2e-release-slug-background")); + await electronApp.evaluate(async ({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.setSize(760, 500); }); - await expect(glyphCanvas).toBeVisible({ timeout: 2_000 }); + await afterNextPaint(page); await expect - .poll(() => glyphCanvas.getAttribute("data-fully-resident"), { timeout: 120_000 }) - .toBe("true"); - if (glyphCount >= 5_000) { - const residencyPageLoads = await page.evaluate( - () => JSON.parse(document.documentElement.dataset.slugPageRootCounts ?? "[]") as number[], - ); - expect(residencyPageLoads.length).toBeGreaterThan(2); - expect( - await page.evaluate(() => Number(document.documentElement.dataset.slugPageStreams)), - ).toBeGreaterThan(2); - } - + .poll(() => scrollViewport.evaluate((element) => element.scrollHeight > element.clientHeight)) + .toBe(true); + await trackSlugAtlasLoads(page); await page.evaluate(() => { const canvas = document.querySelector( '[aria-label="Glyph catalog"] + canvas', ); if (!canvas) throw new Error("Expected resident glyph canvas"); + document.documentElement.dataset.slugHiddenTransitions = "0"; - document.documentElement.dataset.slugPageRootCounts = "[]"; - document.documentElement.dataset.slugPageStreams = "0"; + document.documentElement.dataset.slugIgnoreHiddenTransitions = "false"; new MutationObserver(() => { - if (canvas.style.visibility !== "hidden") return; + if ( + canvas.style.visibility !== "hidden" || + document.documentElement.dataset.slugIgnoreHiddenTransitions === "true" + ) { + return; + } + document.documentElement.dataset.slugHiddenTransitions = String( Number(document.documentElement.dataset.slugHiddenTransitions) + 1, ); }).observe(canvas, { attributeFilter: ["style"], attributes: true }); }); - await scrollViewport.evaluate(async (element) => { - const maximum = element.scrollHeight - element.clientHeight; - for (let step = 11; step >= 0; step -= 1) { - element.scrollTop = (maximum * step) / 11; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - for (let step = 1; step <= 11; step += 1) { - element.scrollTop = (maximum * step) / 11; - await new Promise((resolve) => requestAnimationFrame(() => resolve())); - } - }); - await afterNextPaint(page); - - await expect(glyphCanvas).toBeVisible(); - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugHiddenTransitions)) - .toBe("0"); - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugPageRootCounts)) - .toBe("[]"); - await expect - .poll(() => page.evaluate(() => document.documentElement.dataset.slugPageStreams)) - .toBe("0"); - - for (const fraction of [0, 0.5, 1]) { + for (const fraction of [0, 0.5, 1, 0.5, 0]) { await scrollViewport.evaluate((element, nextFraction) => { element.scrollTop = (element.scrollHeight - element.clientHeight) * nextFraction; }, fraction); await afterNextPaint(page); + const paintedFrame = await catalogSurface.screenshot(); const visibility = await glyphCanvas.evaluate((canvas) => { + document.documentElement.dataset.slugIgnoreHiddenTransitions = "true"; const previous = canvas.style.visibility; canvas.style.visibility = "hidden"; return previous; @@ -290,38 +160,22 @@ test.describe("Resident catalog GPU", () => { const frameWithoutGlyphs = await catalogSurface.screenshot(); await glyphCanvas.evaluate((canvas, previous) => { canvas.style.visibility = previous; + document.documentElement.dataset.slugIgnoreHiddenTransitions = "false"; }, visibility); expect(paintedFrame.equals(frameWithoutGlyphs)).toBe(false); } - const search = page.getByPlaceholder("Search glyphs..."); - await search.fill("A"); - await expect(glyphCanvas).toHaveAttribute("data-fully-resident", "true"); - await expect(glyphCanvas).toBeVisible(); - await afterNextPaint(page); - await search.fill(""); - await expect(glyphCanvas).toHaveAttribute("data-fully-resident", "true"); - await electronApp.evaluate(async ({ BrowserWindow }) => { - BrowserWindow.getAllWindows()[0]?.setSize(1100, 700); - }); - await afterNextPaint(page); await expect(glyphCanvas).toBeVisible(); - await expect(glyphCanvas).toHaveAttribute("data-fully-resident", "true"); - const resizedFrame = await catalogSurface.screenshot(); - const resizedVisibility = await glyphCanvas.evaluate((canvas) => { - const previous = canvas.style.visibility; - canvas.style.visibility = "hidden"; - return previous; - }); - const resizedFrameWithoutGlyphs = await catalogSurface.screenshot(); - await glyphCanvas.evaluate((canvas, previous) => { - canvas.style.visibility = previous; - }, resizedVisibility); - expect(resizedFrame.equals(resizedFrameWithoutGlyphs)).toBe(false); - expect(await page.evaluate(() => document.documentElement.dataset.slugPageRootCounts)).toBe( - "[]", - ); - expect(await page.evaluate(() => document.documentElement.dataset.slugPageStreams)).toBe("0"); + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.slugHiddenTransitions)) + .toBe("0"); + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.slugCompleteAtlasPrepares)) + .toBe("0"); + await expect + .poll(() => page.evaluate(() => document.documentElement.dataset.slugPatchRootCounts)) + .toBe("[]"); + expect(errors).toEqual([]); }); test("restores the current viewport after a topology edit", async ({ page }) => { @@ -334,7 +188,7 @@ test.describe("Resident catalog GPU", () => { await navigateToEditor(page, "53"); await trackSlugFrameSubmits(page); - await trackSlugPageLoads(page); + await trackSlugAtlasLoads(page); const editStarted = performance.now(); await page.evaluate(async () => { @@ -371,29 +225,35 @@ test.describe("Resident catalog GPU", () => { const recoveryDuration = performance.now() - returnStarted; const refreshDuration = performance.now() - editStarted; - const pageLoad = await page.evaluate(() => ({ - counts: JSON.parse(document.documentElement.dataset.slugPageRootCounts ?? "[]") as number[], + const atlasLoads = await page.evaluate(() => ({ + complete: Number(document.documentElement.dataset.slugCompleteAtlasPrepares), + patches: JSON.parse(document.documentElement.dataset.slugPatchRootCounts ?? "[]") as number[], glyphCount: window.shift?.font.glyphRecords().length ?? 0, })); console.log( `Resident catalog topology recovery took ${recoveryDuration.toFixed(0)}ms (${refreshDuration.toFixed(0)}ms from edit)`, ); expect(recoveryDuration).toBeLessThan(1_000); - expect(pageLoad.counts[0]).toBeGreaterThan(0); - expect(pageLoad.counts[0]).toBeLessThan(pageLoad.glyphCount); + expect(atlasLoads.complete).toBe(0); + expect(atlasLoads.patches).toHaveLength(1); + expect(atlasLoads.patches[0]).toBeGreaterThan(0); + expect(atlasLoads.patches[0]).toBeLessThan(atlasLoads.glyphCount); }); - test("prioritizes a distant viewport after a topology edit", async ({ page }) => { + test("keeps distant glyphs resident after a topology patch", async ({ electronApp, page }) => { await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); const scrollViewport = page.getByLabel("Glyph catalog"); await scrollViewport.waitFor({ state: "visible" }); const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); - const scrollable = await scrollViewport.evaluate( - (element) => element.scrollHeight > element.clientHeight, - ); - test.skip(!scrollable, "Catalog needs more than one viewport"); + await electronApp.evaluate(async ({ BrowserWindow }) => { + BrowserWindow.getAllWindows()[0]?.setSize(760, 500); + }); + await afterNextPaint(page); + await expect + .poll(() => scrollViewport.evaluate((element) => element.scrollHeight > element.clientHeight)) + .toBe(true); await navigateToEditor(page, "53"); await page.evaluate(async () => { @@ -437,7 +297,7 @@ test.describe("Resident catalog GPU", () => { expect(scrollDuration).toBeLessThan(1_000); }); - test("restores the current viewport after deleting a source and axis", async ({ page }) => { + test("rebuilds the complete atlas after a source change", async ({ page }) => { await expect.poll(() => page.evaluate(() => Boolean(navigator.gpu))).toBe(true); const scrollViewport = page.getByLabel("Glyph catalog"); @@ -447,21 +307,15 @@ test.describe("Resident catalog GPU", () => { await navigateToEditor(page, "53"); await trackSlugFrameSubmits(page); - const changed = await page.evaluate(async () => { + await trackSlugAtlasLoads(page); + await page.evaluate(async () => { const font = window.shift?.font; - if (!font || font.getAxes().length === 0 || font.sources.length < 2) return false; - - const source = font.sources.find((candidate) => candidate.id !== font.defaultSource.id); - const axis = font.getAxes().at(-1); - if (!source || !axis) return false; + const source = font?.sources[0]; + if (!font || !source) throw new Error("Expected a source for global invalidation"); - font.deleteSource(source.id); - await font.editCoordinator.settled(); - font.deleteAxis(axis.id); + await font.updateSource({ ...source, name: `${source.name} E2E` }); await font.editCoordinator.settled(); - return true; }); - test.skip(!changed, "Font needs at least one axis and two sources"); const returnStarted = performance.now(); await page.getByRole("button", { name: "Display all glyphs" }).click(); @@ -474,8 +328,13 @@ test.describe("Resident catalog GPU", () => { await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); const recoveryDuration = performance.now() - returnStarted; - console.log(`Resident catalog source/axis recovery took ${recoveryDuration.toFixed(0)}ms`); + const atlasLoads = await page.evaluate(() => ({ + complete: Number(document.documentElement.dataset.slugCompleteAtlasPrepares), + patches: document.documentElement.dataset.slugPatchRootCounts, + })); + console.log(`Resident catalog source recovery took ${recoveryDuration.toFixed(0)}ms`); expect(recoveryDuration).toBeLessThan(1_000); + expect(atlasLoads).toEqual({ complete: 1, patches: "[]" }); }); }); @@ -497,20 +356,28 @@ async function trackSlugFrameSubmits(page: Page): Promise { }); } -async function trackSlugPageLoads(page: Page): Promise { +async function trackSlugAtlasLoads(page: Page): Promise { await page.evaluate(() => { const coordinator = window.shift?.font.editCoordinator; if (!coordinator) throw new Error("Expected workspace edit coordinator"); - const originalPrepare = coordinator.prepareSlugAtlasPage.bind(coordinator); - document.documentElement.dataset.slugPageRootCounts = "[]"; + const originalCompletePrepare = coordinator.prepareSlugAtlas.bind(coordinator); + const originalPatchPrepare = coordinator.prepareSlugAtlasPage.bind(coordinator); + document.documentElement.dataset.slugCompleteAtlasPrepares = "0"; + document.documentElement.dataset.slugPatchRootCounts = "[]"; + coordinator.prepareSlugAtlas = async (alignment) => { + document.documentElement.dataset.slugCompleteAtlasPrepares = String( + Number(document.documentElement.dataset.slugCompleteAtlasPrepares) + 1, + ); + return originalCompletePrepare(alignment); + }; coordinator.prepareSlugAtlasPage = async (glyphIds, alignment) => { const counts = JSON.parse( - document.documentElement.dataset.slugPageRootCounts ?? "[]", + document.documentElement.dataset.slugPatchRootCounts ?? "[]", ) as number[]; counts.push(glyphIds.length); - document.documentElement.dataset.slugPageRootCounts = JSON.stringify(counts); - return originalPrepare(glyphIds, alignment); + document.documentElement.dataset.slugPatchRootCounts = JSON.stringify(counts); + return originalPatchPrepare(glyphIds, alignment); }; }); } diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts index 1f0a21cc..2751e7d7 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogController.ts @@ -17,8 +17,6 @@ import type { } from "@/types/glyphCatalog"; import type { GlyphPreviewInstance } from "@/types/glyphPreview"; -const BACKGROUND_PAGE_SIZE = 2048; - /** Owns catalog DOM events, frame scheduling, layout, and resident rendering. */ export class GlyphCatalogController { readonly #container: HTMLDivElement; @@ -37,7 +35,7 @@ export class GlyphCatalogController { #frame: GlyphCatalogControllerFrame | null = null; #layer: ResidentGlyphLayer | null = null; - /** Single native page operation; aborted work retains this slot until it actually settles. */ + /** Single native atlas operation; aborted work retains this slot until it actually settles. */ #refresh: AbortController | null = null; #pointer: Point2D | null = null; #hoveredCatalogIndex: number | null = null; @@ -77,10 +75,11 @@ export class GlyphCatalogController { void this.#redrawWhenFontsReady(); this.#fontEffect = effect( - () => { - const invalidGlyphIds = font.invalidGlyphIdsCell.value; - this.#invalidate(invalidGlyphIds ?? font.glyphRecords().map((glyph) => glyph.id)); - }, + () => + this.#invalidate( + font.invalidGlyphIdsCell.value, + font.glyphRecords().map((glyph) => glyph.id), + ), { name: "glyphCatalog.residentFont" }, ); this.#startLayer(); @@ -145,11 +144,29 @@ export class GlyphCatalogController { document.fonts.removeEventListener("loadingdone", this.#handleFontsLoaded); } - #invalidate(glyphIds: readonly GlyphId[]): void { - for (const glyphId of glyphIds) this.#invalidGlyphIds.add(glyphId); + #invalidate(glyphIds: readonly GlyphId[] | null, fontGlyphIds: readonly GlyphId[]): void { + if (glyphIds === null) { + this.#invalidGlyphIds.clear(); + this.#refresh?.abort(new Error("resident font changed")); + this.#layer?.destroy(); + this.#layer = null; + this.#glyphCanvas.dataset.fullyResident = "false"; + this.#firstFrameStarted = false; + this.#needsRedraw = true; + this.#onReadyChange(false); + + if (this.#frame?.active) this.#startLayer(); + return; + } + if (glyphIds.length === 0) return; + this.#layer?.invalidate(glyphIds); + const fontGlyphIdSet = new Set(fontGlyphIds); + for (const glyphId of glyphIds) { + if (fontGlyphIdSet.has(glyphId)) this.#invalidGlyphIds.add(glyphId); + } this.#updateFullyResident(); - this.#refresh?.abort(new Error("resident font changed")); + this.#refresh?.abort(new Error("resident glyphs changed")); this.#firstFrameStarted = false; this.#needsRedraw = true; this.#onReadyChange(false); @@ -179,9 +196,12 @@ export class GlyphCatalogController { } this.#layer = layer; + this.#invalidGlyphIds.clear(); this.#updateFullyResident(); this.#refresh = null; - if (this.#frame?.active) await this.#refreshVisible(); + this.#needsRedraw = true; + this.#firstFrameStarted = false; + this.redraw(); } catch (error) { if (this.#disposed || this.#refresh !== refresh) return; this.#refresh = null; @@ -202,25 +222,25 @@ export class GlyphCatalogController { return; } - const pageGlyphIds = this.#viewportPageGlyphIds(); - const missingGlyphIds = pageGlyphIds.filter( - (glyphId) => this.#invalidGlyphIds.has(glyphId) || !layer.hasGlyphs([glyphId]), - ); - if (missingGlyphIds.length === 0) { - if (pageGlyphIds.length === 0) this.#onReadyChange(true); + const missingGlyphIds = new Set(this.#invalidGlyphIds); + for (const cell of this.#currentFrame().cells) { + if (!layer.hasGlyphs([cell.glyph.id])) missingGlyphIds.add(cell.glyph.id); + } + if (missingGlyphIds.size === 0) { + this.#updateFullyResident(); this.redraw(); - if (!this.#refresh) this.#startBackgroundRefresh(pageGlyphIds); return; } + const glyphIds = [...missingGlyphIds]; const refresh = new AbortController(); this.#refresh = refresh; this.#onReadyChange(false); try { - await layer.loadPage(missingGlyphIds, refresh.signal); + await layer.loadPatch(glyphIds, refresh.signal); if (this.#disposed || this.#refresh !== refresh) return; - for (const glyphId of missingGlyphIds) this.#invalidGlyphIds.delete(glyphId); + for (const glyphId of glyphIds) this.#invalidGlyphIds.delete(glyphId); this.#updateFullyResident(); this.#refresh = null; this.#needsRedraw = true; @@ -237,70 +257,6 @@ export class GlyphCatalogController { } } - #startBackgroundRefresh(pageGlyphIds: readonly GlyphId[]): void { - const refresh = new AbortController(); - this.#refresh = refresh; - void this.#refreshRemaining(refresh, pageGlyphIds).catch((error: unknown) => { - if (this.#disposed || this.#refresh !== refresh) return; - this.#refresh = null; - if (refresh.signal.aborted) { - if (this.#frame?.active) void this.#refreshVisible(); - return; - } - if (this.#layer) this.#failFrame(this.#layer, error); - }); - } - - async #refreshRemaining( - refresh: AbortController, - pageGlyphIds: readonly GlyphId[], - ): Promise { - const layer = this.#layer; - const input = this.#frame; - if (!layer || !input) return; - - const pageSet = new Set(pageGlyphIds); - const remaining = input.glyphs - .map((glyph) => glyph.id) - .filter( - (glyphId) => - !pageSet.has(glyphId) && - (this.#invalidGlyphIds.has(glyphId) || !layer.hasGlyphs([glyphId])), - ); - const ordered = outwardGlyphIds(input.glyphs, remaining, pageGlyphIds); - - let visibleNeedsRefresh = false; - for (let offset = 0; offset < ordered.length; offset += BACKGROUND_PAGE_SIZE) { - refresh.signal.throwIfAborted(); - const visibleGlyphIds = this.#viewportPageGlyphIds(); - visibleNeedsRefresh = visibleGlyphIds.some( - (glyphId) => this.#invalidGlyphIds.has(glyphId) || !layer.hasGlyphs([glyphId]), - ); - if (visibleNeedsRefresh) break; - - const glyphIds = ordered.slice(offset, offset + BACKGROUND_PAGE_SIZE); - await layer.loadPage(glyphIds, refresh.signal); - if (this.#disposed || this.#refresh !== refresh) return; - for (const glyphId of glyphIds) this.#invalidGlyphIds.delete(glyphId); - this.#updateFullyResident(); - this.#needsRedraw = true; - this.#firstFrameStarted = false; - this.redraw(); - - visibleNeedsRefresh = this.#viewportPageGlyphIds().some( - (glyphId) => this.#invalidGlyphIds.has(glyphId) || !layer.hasGlyphs([glyphId]), - ); - if (visibleNeedsRefresh) break; - } - - if (this.#refresh !== refresh) return; - this.#refresh = null; - if (visibleNeedsRefresh) { - void this.#refreshVisible(); - this.redraw(); - } - } - #handleDeviceLoss(reason: string): void { if (this.#disposed) return; console.error("resident glyph device lost", reason); @@ -326,10 +282,6 @@ export class GlyphCatalogController { return layout.frame(this.#frame?.glyphs ?? [], this.#container.scrollTop); } - #viewportPageGlyphIds(): GlyphId[] { - return viewportPageGlyphIds(this.#frame?.glyphs ?? [], this.#currentFrame()); - } - #draw(): void { if (this.#disposed) return; const input = this.#frame; @@ -424,7 +376,6 @@ export class GlyphCatalogController { return; } this.#onReadyChange(true); - if (!this.#refresh) this.#startBackgroundRefresh(this.#viewportPageGlyphIds()); } catch (error) { if (this.#disposed || this.#layer !== layer) return; this.#failFrame(layer, error); @@ -519,48 +470,3 @@ export class GlyphCatalogController { } } } - -function viewportPageGlyphIds( - glyphs: readonly GlyphCatalogItem[], - frame: GlyphCatalogFrame, -): GlyphId[] { - if (frame.cells.length === 0) return []; - - const firstIndex = frame.cells[0]!.catalogIndex; - const lastIndex = frame.cells.at(-1)!.catalogIndex; - const viewportCount = lastIndex - firstIndex + 1; - const start = Math.max(0, firstIndex - viewportCount); - const end = Math.min(glyphs.length, lastIndex + viewportCount + 1); - return glyphs.slice(start, end).map((glyph) => glyph.id); -} - -function outwardGlyphIds( - glyphs: readonly GlyphCatalogItem[], - remainingGlyphIds: readonly GlyphId[], - pageGlyphIds: readonly GlyphId[], -): GlyphId[] { - const remaining = new Set(remainingGlyphIds); - const firstPageIndex = - pageGlyphIds.length > 0 ? glyphs.findIndex((glyph) => glyph.id === pageGlyphIds[0]) : 0; - const lastPageGlyphId = pageGlyphIds.at(-1); - const lastPageIndex = lastPageGlyphId - ? glyphs.findIndex((glyph) => glyph.id === lastPageGlyphId) - : firstPageIndex; - const ordered: GlyphId[] = []; - let before = firstPageIndex - 1; - let after = lastPageIndex + 1; - - while (before >= 0 || after < glyphs.length) { - for (let count = 0; count < BACKGROUND_PAGE_SIZE && after < glyphs.length; count += 1) { - const glyphId = glyphs[after++]!.id; - if (remaining.delete(glyphId)) ordered.push(glyphId); - } - for (let count = 0; count < BACKGROUND_PAGE_SIZE && before >= 0; count += 1) { - const glyphId = glyphs[before--]!.id; - if (remaining.delete(glyphId)) ordered.push(glyphId); - } - } - - ordered.push(...remaining); - return ordered; -} diff --git a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts index 62ff4d33..0ae9d3b2 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts +++ b/apps/desktop/src/renderer/src/lib/graphics/backends/ResidentGlyphLayer.ts @@ -6,10 +6,11 @@ import { SlugRenderer } from "@/lib/slug/SlugRenderer"; const COPY_ALIGNMENT = 256; const UPLOAD_CHUNK_BYTES = 4 * 1024 * 1024; -const PREFERRED_PAGE_BYTES = 64 * 1024 * 1024; +const PREFERRED_ATLAS_BYTES = 256 * 1024 * 1024; +const PREFERRED_PATCH_BYTES = 64 * 1024 * 1024; const REQUIRED_STORAGE_BUFFERS = 8; -/** Generic paged glyph-preview surface backed by the current Slug implementation. */ +/** Complete resident glyph-preview surface with incremental edit patches. */ export class ResidentGlyphLayer { readonly #renderer: SlugRenderer; readonly #device: GPUDevice; @@ -39,10 +40,20 @@ export class ResidentGlyphLayer { ): Promise { if (!navigator.gpu) throw new Error("WebGPU is unavailable"); + let preparedGeneration: number | null = null; let device: GPUDevice | null = null; let context: GPUCanvasContext | null = null; + let atlas: SlugAtlas | null = null; let renderer: SlugRenderer | null = null; + async function discardPrepared(): Promise { + if (preparedGeneration === null) return; + + const generation = preparedGeneration; + preparedGeneration = null; + await edits.discardSlugAtlas(generation); + } + try { const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" }); throwIfAborted(signal); @@ -53,8 +64,34 @@ export class ResidentGlyphLayer { ); } + const alignment = Math.max(COPY_ALIGNMENT, adapter.limits.minStorageBufferOffsetAlignment); + const descriptor = await edits.prepareSlugAtlas(alignment); + preparedGeneration = descriptor.generation; + throwIfAborted(signal); + + const adapterMaximumBindingSize = Math.min( + adapter.limits.maxBufferSize, + adapter.limits.maxStorageBufferBindingSize, + ); + const [, firstLength, secondLength] = SlugAtlas.bindingLengths( + descriptor.layout.totalLength, + adapterMaximumBindingSize, + ); + const largestResidentBuffer = Math.max(firstLength, secondLength); + if (descriptor.layout.totalLength > PREFERRED_ATLAS_BYTES) { + console.warn("resident glyph atlas exceeds preferred size", { + bytes: descriptor.layout.totalLength, + preferredBytes: PREFERRED_ATLAS_BYTES, + adapterMaximumBytes: adapterMaximumBindingSize * 2, + }); + } + device = await adapter.requestDevice({ - requiredLimits: { maxStorageBuffersPerShaderStage: REQUIRED_STORAGE_BUFFERS }, + requiredLimits: { + maxBufferSize: largestResidentBuffer, + maxStorageBufferBindingSize: largestResidentBuffer, + maxStorageBuffersPerShaderStage: REQUIRED_STORAGE_BUFFERS, + }, }); throwIfAborted(signal); @@ -63,11 +100,33 @@ export class ResidentGlyphLayer { const format = navigator.gpu.getPreferredCanvasFormat(); context.configure({ device, format, alphaMode: "premultiplied" }); renderer = new SlugRenderer(device, context, format, onDeviceLost); - const alignment = Math.max(COPY_ALIGNMENT, device.limits.minStorageBufferOffsetAlignment); const maximumBindingSize = Math.min( device.limits.maxBufferSize, device.limits.maxStorageBufferBindingSize, ); + atlas = SlugAtlas.create(descriptor, device, maximumBindingSize); + const activeAtlas = atlas; + const uploadDevice = device; + const totalLength = await edits.streamSlugAtlas( + descriptor.generation, + UPLOAD_CHUNK_BYTES, + (offset, bytes) => { + throwIfAborted(signal); + activeAtlas.write(uploadDevice.queue, offset, bytes); + }, + ); + preparedGeneration = null; + throwIfAborted(signal); + if (totalLength !== descriptor.layout.totalLength) { + throw new Error( + `resident glyph stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, + ); + } + + const loadedAtlas = atlas; + atlas = null; + renderer.loadPage(loadedAtlas); + throwIfAborted(signal); const layer = new ResidentGlyphLayer(renderer, device, edits, alignment, maximumBindingSize); renderer = null; @@ -76,34 +135,37 @@ export class ResidentGlyphLayer { return layer; } catch (error) { renderer?.destroy(); + if (renderer) { + context = null; + device = null; + } + atlas?.destroy(); context?.unconfigure(); device?.destroy(); + try { + await discardPrepared(); + } catch (discardError) { + console.error("failed to release rejected resident glyph atlas", discardError); + } throw error; } } - async loadPage(glyphIds: readonly GlyphId[], signal: AbortSignal): Promise { + async loadPatch(glyphIds: readonly GlyphId[], signal: AbortSignal): Promise { if (glyphIds.length === 0) return; let preparedGeneration: number | null = null; let atlas: SlugAtlas | null = null; - async function discardPrepared( - edits: WorkspaceEditCoordinator, - generation: number, - ): Promise { - await edits.discardSlugAtlasPage(generation); - } - try { const descriptor = await this.#edits.prepareSlugAtlasPage(glyphIds, this.#alignment); preparedGeneration = descriptor.generation; throwIfAborted(signal); - if (descriptor.layout.totalLength > PREFERRED_PAGE_BYTES) { - console.warn("resident glyph atlas page exceeds preferred size", { + if (descriptor.layout.totalLength > PREFERRED_PATCH_BYTES) { + console.warn("resident glyph atlas patch exceeds preferred size", { bytes: descriptor.layout.totalLength, - preferredBytes: PREFERRED_PAGE_BYTES, + preferredBytes: PREFERRED_PATCH_BYTES, }); } @@ -121,7 +183,7 @@ export class ResidentGlyphLayer { throwIfAborted(signal); if (totalLength !== descriptor.layout.totalLength) { throw new Error( - `resident glyph page stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, + `resident glyph patch stream wrote ${totalLength} bytes; expected ${descriptor.layout.totalLength}`, ); } @@ -132,9 +194,9 @@ export class ResidentGlyphLayer { atlas?.destroy(); if (preparedGeneration !== null) { try { - await discardPrepared(this.#edits, preparedGeneration); + await this.#edits.discardSlugAtlasPage(preparedGeneration); } catch (discardError) { - console.error("failed to release rejected resident glyph atlas page", discardError); + console.error("failed to release rejected resident glyph atlas patch", discardError); } } throw error; diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index 4ca6b551..02c41639 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -8,9 +8,9 @@ Renderer vector-path values and the accelerated marker-layer backend for editor - **Architecture Invariant:** `Renderer` owns the `MarkerLayer` lifecycle. `CanvasContextProvider` only reports DOM canvas mount, resize, and unmount events. -- **Architecture Invariant:** `ResidentGlyphLayer` is the generic catalog-preview boundary. It owns one WebGPU adapter/device/context and independently replaceable atlas pages; Slug names and packed-layout knowledge remain behind that backend rather than entering React or catalog frame types. +- **Architecture Invariant:** `ResidentGlyphLayer` is the generic catalog-preview boundary. It owns one WebGPU adapter/device/context, one complete base atlas, and independently replaceable edit patches; Slug names and packed-layout knowledge remain behind that backend rather than entering React or catalog frame types. -- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw from current resident pages because Chromium may discard the WebGPU canvas presentation; it does not compile or upload those pages. Readiness means the current viewport plus overscan is current and its submitted frame completed. +- **Architecture Invariant:** Catalog route activity and GPU readiness are independent. Leaving `/home` makes the catalog inert and keeps it painted behind the opaque editor without destroying the resident layer or resizing its canvas. Returning submits one cheap redraw because Chromium may discard the WebGPU canvas presentation. Readiness means the complete atlas, including any required local edit patches, is current and its submitted frame completed. - **Architecture Invariant:** **CRITICAL**: The instance buffer layout (attribute offsets in the draw command) must exactly match the packing order in `MarkerHandleRenderer.#writeInstance`. If either side changes stride/offset, handles render garbage with no error. @@ -28,7 +28,7 @@ graphics/ canvasText.ts — width-constrained Canvas2D label fitting backends/ MarkerLayer.ts — WebGL context: REGL init, instance buffer management, draw command - ResidentGlyphLayer.ts — WebGPU catalog device, page upload, draw, invalidation, and teardown boundary + ResidentGlyphLayer.ts — WebGPU catalog device, complete upload, edit patches, draw, and teardown ``` Supporting files live in the editor rendering module: @@ -51,7 +51,7 @@ editor/rendering/markers/ - `MarkerLayer` -- WebGL context wrapper. Manages REGL instance, instance buffer, and draw command. Provides `resizeCanvas`, `draw`, `clear`, `destroy`, and `isAvailable`. -- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It keeps one device/context resident, prepares and streams native Slug pages, invalidates roots independently, and delegates multi-page rendering to the internal Slug backend. +- `ResidentGlyphLayer` -- algorithm-neutral surface used by the catalog controller. It prepares and streams one complete native Slug atlas, retains one device/context, and overlays small replacement patches for locally invalidated roots. - `MarkerInstance` -- logical representation of one marker shape. The current marker path packs directly into a `Float32Array` for zero steady-state allocation. @@ -73,7 +73,7 @@ editor/rendering/markers/ ### Resident catalog lifecycle -`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Local edits invalidate only touched roots and component dependents; axis/source changes invalidate every root. The remembered viewport plus one viewport of overscan loads as the first page, then 2,048-root pages populate outward in both directions. At most one native page compilation is in flight: scrolling redraws already-resident roots immediately, and when that compilation settles the scheduler rechecks the latest viewport before starting anything else. Every published background page schedules a current-viewport redraw, even when that publication itself satisfies a viewport that became visible while the page was in flight. Intermediate scrub positions therefore cannot queue native compilation work. Once all pages are resident, `GlyphCatalogController` marks the glyph canvas `data-fully-resident="true"`; thumb scrubbing then neither hides the canvas nor prepares or uploads atlas data. Invalidation clears full residency until every current catalog root is uploaded again. Invalid or missing roots are never submitted, so stale previews are not displayed. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. +`GlyphCatalogController` retains `ResidentGlyphLayer` across routes and tracks `Font.invalidGlyphIdsCell`. Initial residency acquires every authored layer, streams one complete atlas, and marks the glyph canvas `data-fully-resident="true"`. Scrolling in either direction therefore only submits frames and never prepares or uploads geometry. Local edits invalidate touched roots and component dependents, then stream one replacement patch while untouched roots remain in the complete base atlas. Axis/source changes discard the complete atlas and rebuild it because every root is invalid. Invalid or missing roots are never submitted, so stale previews are not displayed. Route-dependent navigation is accessed through a stable callback ref so it cannot recreate the controller or device. `#needsRedraw` keeps overlay-only pointer updates from submitting glyph frames. ### Per-frame draw pipeline @@ -120,7 +120,7 @@ Set a breakpoint or add logging in `MarkerLayer.draw`. Check `isAvailable()` ret ## Verification -- Run `pnpm --filter @shift/desktop test:e2e -- --project=gpu e2e/gpu.spec.ts` after changing catalog residency or scheduling. The suite blocks background publication to verify visible-first painting, verifies that publication restores a viewport reached while the page was in flight, and samples top/middle/bottom screenshots while asserting that a fully resident atlas never hides, prepares, or uploads during thumb scrubbing. +- Run `pnpm --filter @shift/desktop test:e2e -- --project=gpu e2e/gpu.spec.ts` after changing catalog residency. The suite samples the catalog while scrolling down and back up, verifies that the complete atlas never hides or reloads, checks local replacement patches, and requires a complete rebuild after global axis/source invalidation. - Run `pnpm test:desktop apps/desktop/src/renderer/src/lib/graphics/ContourPath.test.ts` after changing contour command, transform, SVG, Canvas, or bounds behavior. - No dedicated tests exist for `MarkerLayer`. Verify GPU markers visually: open a glyph with mixed point types (corner, smooth, off-curve), hover and select points, confirm correct shapes and state colors. - Check CPU fallback: in `MarkerLayer.#initialize`, temporarily throw before `this.#available = true`. Handles should still render via Canvas 2D. diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index 685dd016..f9375869 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -473,7 +473,7 @@ export class Font { return this.#committedFontCell; } - /** Glyph roots whose resident atlas pages no longer match the committed font. */ + /** Glyph roots whose resident atlas entries no longer match the committed font. */ get invalidGlyphIdsCell(): Signal { return this.#store.invalidGlyphIdsCell; } diff --git a/apps/desktop/src/renderer/src/lib/model/FontStore.ts b/apps/desktop/src/renderer/src/lib/model/FontStore.ts index 2208ff4f..dd11152e 100644 --- a/apps/desktop/src/renderer/src/lib/model/FontStore.ts +++ b/apps/desktop/src/renderer/src/lib/model/FontStore.ts @@ -97,7 +97,7 @@ export class FontStore { return this.#committedFont; } - /** Glyph roots whose resident atlas pages no longer match the committed font. */ + /** Glyph roots whose resident atlas entries no longer match the committed font. */ get invalidGlyphIdsCell(): Signal { return this.#invalidGlyphIds; } diff --git a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts index 29158cc0..93df34fe 100644 --- a/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts +++ b/apps/desktop/src/renderer/src/lib/slug/SlugRenderer.ts @@ -4,7 +4,7 @@ import { SlugAtlas } from "./SlugAtlas"; import { SlugAtlasPage } from "./SlugAtlasPage"; import { SlugRendererPipelines } from "./SlugRendererResources"; -/** Concrete paged Slug implementation behind the resident glyph preview layer. */ +/** Complete Slug atlas renderer with independently replaceable glyph patches. */ export class SlugRenderer { readonly #device: GPUDevice; readonly #context: GPUCanvasContext; diff --git a/crates/shift-bridge/__test__/index.spec.mjs b/crates/shift-bridge/__test__/index.spec.mjs index 42da4d0b..ba3a4390 100644 --- a/crates/shift-bridge/__test__/index.spec.mjs +++ b/crates/shift-bridge/__test__/index.spec.mjs @@ -3,6 +3,7 @@ import { createRequire } from "module"; import { existsSync, mkdtempSync, rmSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import { fileURLToPath } from "url"; const require = createRequire(import.meta.url); const { Bridge } = require("../index.js"); @@ -10,6 +11,7 @@ const { Bridge } = require("../index.js"); const shortIdAlphabet = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict"; const shortIdLength = 10; const shortIdAlphabetMask = shortIdAlphabet.length - 1; +const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url)); function mintId(prefix) { const bytes = new Uint8Array(shortIdLength); @@ -147,6 +149,37 @@ describe("Bridge", () => { expect(existsSync(outputPath)).toBe(true); }); + function resumeMutatorPackage() { + const sourcePath = join(repositoryRoot, "fixtures/fonts/mutatorsans/MutatorSans.ttf"); + const storePath = join(tempDir, "mutatorsans.sqlite"); + const packagePath = join(tempDir, "MutatorSans.shift"); + bridge.openWorkspace(sourcePath, storePath); + bridge.saveWorkspaceAs(packagePath); + bridge.closeWorkspace(); + bridge.resumeWorkspaceForSource(storePath, packagePath); + } + + it("acquires every lazy glyph payload before preparing the complete Slug atlas", () => { + resumeMutatorPackage(); + + const atlas = bridge.prepareSlugAtlas(256); + expect(atlas.glyphs).toHaveLength(bridge.getGlyphs().length); + expect(atlas.curveCount).toBeGreaterThan(0); + expect(atlas.layout.totalLength).toBeGreaterThan(0); + }); + + it("acquires lazy glyph payloads before preparing a Slug atlas patch", () => { + resumeMutatorPackage(); + + const glyph = bridge.getGlyphs().find((record) => record.name === "S"); + expect(glyph).toBeDefined(); + + const atlas = bridge.prepareSlugAtlasPage([glyph.id], 256); + expect(atlas.glyphs).toContainEqual(expect.objectContaining({ glyphId: glyph.id })); + expect(atlas.curveCount).toBeGreaterThan(0); + expect(atlas.layout.totalLength).toBeGreaterThan(0); + }); + it("adds a point and echoes structure, values, and the client-minted ids", () => { const layerId = createGlyphLayer(); const contourId = addContour(layerId); diff --git a/crates/shift-bridge/docs/DOCS.md b/crates/shift-bridge/docs/DOCS.md index 02b30aaa..a0f2f215 100644 --- a/crates/shift-bridge/docs/DOCS.md +++ b/crates/shift-bridge/docs/DOCS.md @@ -14,7 +14,7 @@ NAPI bindings that expose the Rust font engine to Node.js and Electron as a `Bri **Architecture Invariant:** Export explicitly acquires all persisted layers before taking a clone/COW `FontSaveSnapshot`. **WHY:** Async export gets a complete stable view while ordinary workspace open remains directory-first and lazy. -**Architecture Invariant:** Glyph snapshot, projection, and preview methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans. +**Architecture Invariant:** Glyph snapshot, projection, preview, and Slug preparation methods are explicit acquisition boundaries. They may load requested BLOBs in the serialized utility process; retained renderer glyph objects and synchronous getters never initiate I/O. Component closure comes from the relational reference index rather than BLOB scans, and acquired payloads remain resident in the workspace cache. **Architecture Invariant:** `shift-font` constructs typed glyph and source-metric interpolation; renderer code only evaluates their flattened transport snapshots. **WHY:** Per-location canvas work stays cheap without moving variation-model construction or value-layout ownership into transport code. @@ -64,7 +64,7 @@ crates/shift-bridge/ 7. `inspectPackage(path)` and `inspectPackageDraft(storePath)` expose source/package identity for the utility process without choosing a recovery policy. 8. `closeWorkspace()` drops the live Rust workspace handle before the utility process deletes a clean or discarded SQLite document. 9. `exportWorkspace(request)` creates a `FontSaveSnapshot` and exports asynchronously through `shift-backends`. -10. `prepareSlugAtlasPage(glyphIds, alignment)` compiles only ordered roots and their component closures; its stream/discard methods retain the bounded complete-atlas transport contract. `prepareSlugAtlas(alignment)` remains the complete-font diagnostic path. Every font edit invalidates an unconsumed generation or page. +10. `prepareSlugAtlas(alignment)` acquires all layers for the catalog's complete resident generation. `prepareSlugAtlasPage(glyphIds, alignment)` acquires the ordered roots and their indexed component closures for a local edit patch. Acquired payloads remain in the workspace cache, while stream/discard methods retain the bounded atlas transport contract. Every font edit invalidates an unconsumed generation or patch. ## Type Boundary diff --git a/crates/shift-bridge/src/bridge.rs b/crates/shift-bridge/src/bridge.rs index bb1c881b..729a61f9 100644 --- a/crates/shift-bridge/src/bridge.rs +++ b/crates/shift-bridge/src/bridge.rs @@ -840,6 +840,7 @@ impl Bridge { /// geometry remains native until `stream_slug_atlas` emits bounded chunks. #[napi] pub fn prepare_slug_atlas(&mut self, alignment: u32) -> errors::Result { + self.workspace_mut()?.acquire_all_layers()?; let authored = build_authored_atlas(self.font()?, shift_slug::DEFAULT_BAND_COUNT)?; let layout = authored.atlas().layout(alignment as usize)?; self.slug_generation = self @@ -870,8 +871,8 @@ impl Bridge { .iter() .map(|glyph_id| parse::(glyph_id)) .collect::>>()?; - let authored = - build_authored_atlas_page(self.font()?, &glyph_ids, shift_slug::DEFAULT_BAND_COUNT)?; + let font = self.acquire_and_font(&glyph_ids, AcquireScope::ComponentClosure)?; + let authored = build_authored_atlas_page(font, &glyph_ids, shift_slug::DEFAULT_BAND_COUNT)?; let layout = authored.atlas().layout(alignment as usize)?; self.slug_generation = self .slug_generation diff --git a/crates/shift-slug/docs/DOCS.md b/crates/shift-slug/docs/DOCS.md index 5209534e..3500fcaf 100644 --- a/crates/shift-slug/docs/DOCS.md +++ b/crates/shift-slug/docs/DOCS.md @@ -27,7 +27,7 @@ src/ render.rs shared uniform and visible-instance byte layouts variable.rs multi-source resident base/delta model and aligned packing variable/component.rs component records, CPU oracle, and component packing - resident.rs complete-font/page authored atlases, identity maps, and weight-basis deduplication + resident.rs complete authored atlas, edit patches, identity maps, and basis deduplication error.rs strict conversion and size failures shaders/ slug.wgsl shared static native-wgpu/Electron-WebGPU renderer @@ -58,7 +58,7 @@ Each glyph owns `band_count` horizontal ranges followed by `band_count` vertical ## Resident variable execution -`build_authored_atlas()` remains the complete-font diagnostic boundary. Product residency uses `build_authored_atlas_page()`: it compiles an ordered root-glyph batch plus transitive component geometry, preserves explicit `GlyphId` mapping, and deduplicates interpolation bases only across that page closure. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Pages are location-independent; axis movement changes only each page's shared weight vector and visible instances. Complete-font construction delegates to one page containing every root, so page and diagnostic output share the same compiler and packed layout. +`build_authored_atlas()` is the product complete-font residency boundary. It delegates to `build_authored_atlas_page()` with every root so complete residency and local edit patches share one compiler and packed layout. A patch compiles an ordered root-glyph batch plus transitive component geometry, preserves explicit `GlyphId` mapping, and deduplicates interpolation bases across that closure. Layerless root records receive zero-curve/zero-advance descriptors so one incomplete draft cannot disable the rest of the grid. Complete atlases and patches are location-independent; axis movement changes only their shared weight vectors and visible instances. The variable model keeps one base quadratic array plus base-relative `f32` source deltas. Each 8-byte source descriptor remains dense by default; only a source whose unchanged curves make sparse storage strictly smaller receives a tagged offset into a compact side table of sorted glyph-local indexes. Dense fonts therefore pay no sparse metadata tax. Each source references a global weight index so equal interpolation bases share a small per-frame weight vector. The complete packed atlas remains one logical byte stream but may span two `array` storage bindings. A split offset and every logical section offset fit in the existing 64-byte uniform; one accessor selects the physical buffer without changing packed bytes. Typed WGSL decoders preserve the exact little-endian resident layout while using exactly eight storage bindings in the resolve entry point, matching WebGPU's baseline binding-count and 128 MiB storage-binding limits. Compute preserves the full weighted-source equation as `base × sum(weights) + Σ(weight × delta)`, rather than assuming weights always sum to one. A one-bit-per-curve resident mask marks controls generated from authored lines: after endpoint interpolation, compute regenerates those controls with Slug's normalized perpendicular epsilon because that operation is nonlinear and cannot be represented exactly by source control deltas. One workgroup per visible glyph resolves curves and reduces exact current-location bounds into scratch; a second pass rebuilds the eight horizontal and vertical bands using those bounds. Fragment band selection reads the same scratch bounds. Cell sizing remains a consumer-owned metrics/advance transform, so neither loose all-location bounds nor current-location geometry can shrink or jump the grid layout. Offscreen glyphs perform none of this work until visible. diff --git a/crates/shift-slug/src/resident.rs b/crates/shift-slug/src/resident.rs index 624c5523..5590fba5 100644 --- a/crates/shift-slug/src/resident.rs +++ b/crates/shift-slug/src/resident.rs @@ -64,8 +64,8 @@ pub fn build_authored_atlas( /// Compiles an ordered root-glyph batch and its transitive component geometry. /// /// Root identities are deduplicated in caller order. Interpolation bases are -/// collected only from those roots and their component closures, so viewport -/// work does not scan or compile unrelated glyphs. +/// collected only from those roots and their component closures, so a local +/// edit patch does not scan or compile unrelated glyphs. pub fn build_authored_atlas_page( font: &Font, glyph_ids: &[GlyphId], From aaa3dbd3ca339aa2e3033ba197c2c4b286c725fc Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 31 Jul 2026 17:21:06 -0400 Subject: [PATCH 27/29] Gate visual and GPU E2E correctness --- .github/workflows/ci.yml | 59 ++++++++++-------- .github/workflows/perf.yml | 20 ++---- .../glyph-rendering.spec.ts/bg-S-guides.png | Bin 45469 -> 42126 bytes .../canvas-B-composited.png | Bin 40924 -> 38059 bytes .../canvas-Q-composited.png | Bin 48544 -> 42295 bytes .../canvas-S-all-selected.png | Bin 43292 -> 39422 bytes .../canvas-S-composited.png | Bin 45469 -> 42126 bytes .../handles-S-all-selected.png | Bin 43292 -> 39422 bytes .../handles-S-idle.png | Bin 45469 -> 42126 bytes .../scene-S-filled.png | Bin 45469 -> 42126 bytes apps/desktop/e2e/glyph-rendering.spec.ts | 8 +-- apps/desktop/e2e/home.spec.ts | 20 ++++++ 12 files changed, 62 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f5ff896..ab081562 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -310,12 +310,21 @@ jobs: run: pnpm test:native # --------------------------------------------------------------------------- - # Optional / manual jobs + # Required end-to-end correctness # --------------------------------------------------------------------------- - playwright: - name: Playwright (Manual) - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest + e2e: + name: E2E ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: Visual + project: visual + runner: macos-latest + - name: GPU + project: gpu + runner: macos-latest + runs-on: ${{ matrix.runner }} permissions: contents: read steps: @@ -332,12 +341,7 @@ jobs: - name: Install toolchain run: rustup show - - uses: Swatinem/rust-cache@v2 - with: - shared-key: shift-rust - - name: Cache node_modules - id: modules-cache uses: actions/cache@v6 with: path: | @@ -350,17 +354,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile --prefer-offline - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ hashFiles('apps/desktop/node_modules/@playwright/test/package.json') }} - - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' - run: pnpm --filter @shift/desktop exec playwright install - - name: Cache native module binary id: native-cache uses: actions/cache@v6 @@ -368,20 +361,31 @@ jobs: path: crates/shift-bridge/*.node key: native-module-${{ runner.os }}-${{ hashFiles('crates/**', 'Cargo.lock') }} + - uses: Swatinem/rust-cache@v2 + if: steps.native-cache.outputs.cache-hit != 'true' + with: + shared-key: shift-rust-${{ runner.os }} + - name: Build native module if: steps.native-cache.outputs.cache-hit != 'true' run: pnpm build:native - - name: Run Playwright tests - run: xvfb-run pnpm test:playwright - env: - LIBGL_ALWAYS_SOFTWARE: "true" + - name: Build E2E app + run: pnpm --filter @shift/desktop build:e2e + + - name: Run visual E2E tests + if: matrix.project == 'visual' + run: pnpm --filter @shift/desktop exec playwright test --project=visual + + - name: Run GPU E2E tests + if: matrix.project == 'gpu' + run: pnpm --filter @shift/desktop exec playwright test --project=gpu - - name: Upload snapshots on failure + - name: Upload Playwright failures if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-results + name: playwright-${{ matrix.project }}-results path: apps/desktop/e2e/test-results/ retention-days: 7 @@ -400,6 +404,7 @@ jobs: - rust-fmt - rust-check - integration + - e2e runs-on: ubuntu-latest permissions: {} steps: diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 0c3a6fff..526a58c6 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -119,17 +119,9 @@ jobs: - name: Run engine benchmarks run: pnpm test:perf - playwright: - name: Playwright ${{ matrix.name }} + playwright-perf: + name: Playwright Perf if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - strategy: - fail-fast: false - matrix: - include: - - name: GPU - project: gpu - - name: Perf - project: perf runs-on: macos-latest permissions: contents: read @@ -215,19 +207,19 @@ jobs: - name: Build E2E app run: pnpm --filter @shift/desktop build:e2e - - name: Run Playwright ${{ matrix.name }} tests - run: pnpm --filter @shift/desktop exec playwright test --project=${{ matrix.project }} + - name: Run Playwright perf tests + run: pnpm --filter @shift/desktop exec playwright test --project=perf - name: Upload Playwright failures if: failure() uses: actions/upload-artifact@v7 with: - name: playwright-${{ matrix.project }}-results + name: playwright-perf-results path: apps/desktop/e2e/test-results/ retention-days: 7 - name: Upload perf results - if: ${{ always() && matrix.project == 'perf' }} + if: always() uses: actions/upload-artifact@v7 with: name: perf-results diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/bg-S-guides.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/bg-S-guides.png index 6c306467b709305d1d9197d4d18c6a5ffa7c89be..7183b25afb8f200fb1fb7d99d10b0ce171b58d5e 100644 GIT binary patch literal 42126 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 literal 45469 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 literal 48544 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 literal 43292 zcmeFZXH=6}`!9?-qmGKq*iaD|brhARQX(b9v7uC@ON)r~Djh8#MebUc3&MlZ(ko{~Kz)>Gz)#RWF|J z(S7~;VMDrK&u_O)u5aJ1=ZijZiPSo|_4cmR*2k-U;$M2B{z%@63o83T{TC&~?{AYi zjJ=9~??HP+M!T>W(t67dBr#`8Qqb^5Y{0HWr+2R&35Do=~|`!!p>wEW05FTAW1Wzn~z@WT;r#ZjN%%ZEP= zE4Hl^Y6+T-#_36T{-Ni$B-!7%!YFK(Y}oO_a_|sw)3lu2&vMr;{cabNGslR1;m&sJ z=L^Zz92-Bl3&}b7CS#$7k*^f!*`(W_67;LaVf6h!^?pk8*lDrVV%NDtZP&bWlFvIm zzkmO$#bWcB(~bHysnC0UQA6dyk>=$MHn_rW_fmi6NJ~L2-7zGy@cE@z_5vO&JR)MD zo)gYNmI@ShNz>{5K8$ZIZHT_%As47c+q{_!lewi;+PX{ELzQ z#mKU*@GnOGKVszN&YIExY61U}?SDmve>3L))y5HHk6gCZIWRgmcyo|gc^t@PP6;gs z&<2 z|6h%8a5DebU+vz|iF7kPJ-x`tNWIB0oU4`T)Kq(0c5ZI{m)RGsleL3`VV0I;x2#N* z9nP*ha=Elfodqe+%?!>}FmIy06=D63f=v9rbL%U)P;;DdFO{?uvvkA%Sz21!Ni(gx zxqHqiDJjuT`h~W~%efUAYwPt^LC>6dw$`f@;;e(u&p#a59YGVdO!ALs*Sp@JF=H

9J;TU>dXz&G9b&m9g?f+ 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-composited.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-composited.png index 6c306467b709305d1d9197d4d18c6a5ffa7c89be..7183b25afb8f200fb1fb7d99d10b0ce171b58d5e 100644 GIT binary patch literal 42126 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 literal 45469 zcmeFZXIzt6`z?&)GggW-7E}brQ9-Fnmlg#Tr3gb2q(v&Tv8;74zM{WuTL4k;&XZ6hQPPhe{N3k|)Umk&gVYG3M5c=s-{ zI^L<{^j%G({Rb5tlg?f#Z7w_0{^X9WUL$QfRNw>P~in3=nq(6n)*r8IKdu;R= zR-^b}YjdUVt4%L(q509M@zwb0z%eWA;CtO0tUqolMVD!QPZ(#?X=8c*b8S!929=8^ z%;R02JgB$+ky7;e_WSya4sYHa3OV$nFrRp8*yuyu^>Z&eWv)K{^J3n`^^6I>1bgK> zW}B#E*|tMdN(-Mk;l{-!6Y5;k^9!g_<|QeGi4ypQe1{-MO<}s%dRkdy|~T!|cVX7C!B& zhmXRaIXUx`f3Vq=419xVXJ=iaeK|uKf4shyNIH|NV#MFK4+w4@mDg#QphC0uT56d9LX3 z{L20L?#x5(>GQPyerX5%c<8wIE%%?7ivIoef4A=6u;J#yzbW%?x!}gZzoqiuHUVMq zZ;SjF9&pRSzwqE+c)*Q=f8oKu@PHc!|H6a+>*0aHh21It!v*|1P5;Xz{L7;JUjUPq z9?&PmWA?W8mM9kbxyV4?TLgXC%Y(v4FoT%-7f-4hvJ;JgWBdH)?H&8RCU$m7_u@hl z$(ny%3?=w7^|}8e$1wbttoxU!{FkKtKZMhpIHPRS`blr4l&1S~bsY0a&*vXr-;krK zq|{Bx;ZNfi_ExDyVUu$H15-QaI1u5)g2>3oT(iQ$!jn2vtBm#ab-%eTKR>_7qs$HU z8+c?P`~m`k z`y!V<=jZ3c1Ic6rvG{l3)|WO;Km6runyEy*UZPCJdY_A(k=oJVnd@dIChhq)<*KTx zLrdz~F^OTc*?ZypaUMc=&&1?G@QnoxX3_5*WYmUloSRumzU ze2;lPrAbLi2^M?c()4uVzNvwMfpqQY_4(fR{{E2Y)zl<4HkzLuqc2uo;I-vasN-Ba zHq>OQgj$OlsM~exD48+ThdCT2l{Rb3BWw zs;X)jo~(@AZ`apX>{9AJcpPH@r+MJgjuHN5j7CZq<-ieTx4t5;@tUr#t{0bMs>fz# zW?*SL*RE9->O4OYwDfIve_x+_(5r&6C7cbL>TNtURFr!Q(Ot5@ni|?za2gtZq8Z6PJsj~a*DCXy9hZsBZ&+Z?c`-;sJ^UtOleRsoEl7`TR#?Z>k zZ>xfqVu@PL|6q{Ihmoy=gEne^X=_(~{d(!%y>WSpdYo;n=v0JyUwgX=5{W!}NNa-_ zxP12vQgb+r=@G?lZ~WnyE{Cl6HuJUV=c|_K?0L)M>EhJOTjDWHB8GKEoF{~AEM{kC zx3;$S_VyMO6hQb1P4P2}+A;735F#@{4qI#3>KlQMjiF(vhd^ zZLv^ZQ|!_`q;BWw>1l5-##<>Q ztRA=&MPWH;i*B!e8~&t0WbAs+aavOrKTb}$^|{sV&NveO;5JC zsOWU3Oi+WNil(N>uDraw^cON{=Mt5zEkX1};bByGtIe|zZ+Sl)CWt=e)7;dQrs6dQ z$+xw>P(GOgkYZo~s=(sjbUo5i<^IA#J>}X)`$CuQ{Dp-DzB-(Rlx?~HLR*I3$zKy1 zCIykGW%d@0Hb4I*WvmMz*VI?Ic zi~Xe@^x4khEG^!_FAo}8-|J}a;uAmyO~&hp1by&-6!bA7{qWRk>2}P;3m1NCb1)t} zNF5jD1g=D`B{6$uXlQ5F^3-BY3Bl}rWJ6>%)npY1CmzsXvd~vNCy!<5AB_+TSuIR& z)MQM=*?m?o+_k#8idJS`DIuI*n-Y3zOANe_hfQ~KA0}y_+WXA+PaivWY%k@epIFrfj!XY_PxkxC$w>0O}c>Y0t3~?IZU68&1eUdH@@#4EQ^37=b)$A)u zL!xZ7^_xYCok~jN3d)=$8~W{Fm1xCauU2Z) zM56olMFj<4pPhWaDcQWaC@){g63N6ftw}D_Q79FBUP@~NzC;HKW^1vW7Nr&)wbwk~ zIw?8XGOV)2hk`|LI!p{BasJw_Q2)y2EPf(Us{Eul;iMKMqqw_SRHG|^&FU%1(=!xCgsFe1O9==RxQW%=j#N)ZFK0YIjbF|+d-bS< zZ>I@;8fTl*sBmBM24kY9J+>4zp`kGxy4tIW*l5i46D$?n(#vRjc}hnlaOBa{lo#e|xDn`zetG`*;$=!G^}z9TYSna>hyG}YJ2$)SsawzNQhrE=e$ zEl`vlu-3&r-T%V{gs!hlHWb^$f9cgV@1|4GD-(5VPuOn>GQQNjcA=+T&9}2pOG~Fw z1IKHgDWg80(z%&MmyfH9kYSl2>1H0Gb5^2Zo1;ss3m$05@c#DB%%sLn*L5v#9jnq! zv^b!f_=bH(Jfu%rQd@+8(Ayj)pbtuD{^!r1eO(%q2WE)H#eTlN*$MeJ{cGP38mcpA z-pdk~Dmyw%(~VS6n@hPkQeSK9OUiUrRb5?O--Gd!f4KK`1Z)+OoW?t1!+bZD<=oy2 zed*sN*gPr6s5*uA+w4OIDCT)I;F2Yqo;G8V7S{WceV!#w2Ut_p3*GGOP7<%~!MVA) zjhaNBIQGQhdcmkwM1X)Obrs8D4ON6_7r41()7&l96gHR!%184Ql)DWK1!rex`}+G6 z(Qn(c43Pw1sq8LF=+-)*1}8=#loqI2`-az+MiUz*y=4NH)1$i-l@w5Q0rmG2((?Xi zlmgblb3b`mh}_!N_WQ2g0wTPFtM&1cH|SGu3gzXKT$OD09oKa{n?nt|T&iY>Hzt&M z+B@2LhE^qHxAok8VCPbmPjKBXu1-gl0+^vQohGf*B3^CbO2Ef z+TES20Hgk~{(*)@yMO=wlSnA|8KX4+%&e^T%Wj2ljV=Z(^mPsoW8IG&k{T8~n<`J7 zpm!sO3-0NhO;2h}JoK%58o9j$sLED_%o>TmPqi~Am0svaIWIc8oH{?xJI#C0z3hTxwo!6mnAH6ip)lzTA+%xL*bl9sg4Dc#Wi?35HaMj3 zxU4MkiOLuJyLaz~+zTpq8Xq6OckiCv0m3Mso>)q9vY42dbC5!|d-9>MOg$-WJ9Yng zi@=~CZ>f3m;SQOa^wD%=ZWBeSE?zR@L!Tk;aCD8{ss5A=qEg62QZEOEu2pu$kY%5} zx_tCQlApi7(xJu{$*O5XN?a!8L*KP>0-;Ud#4Bp}!tkc``uGj{uJ_@Qt2AoRFd>RQ z8WX*DG)91rue07`VM1D38d|V2MGEWIn;E;k zzuaEJ)b2HWmKZfB$EUWsyFO`bbF5e}3abx6TFM?>zwh@31%ttwd=^q%Lp$*OBvM`_ zbat(cCS6yw;N&vg8Qg%JpD3VmSH8$ydHmzH;#2YzVQi?=CzVvnM7Of*Q2)wOS5$^e zkE{*hQ03TuS>k5^uARB&vgl42J-GebhxhSkD3Q6jE?!<2u#~L5iV;74_IeTZ!1L-> zA$D)b_D#vF(-bmmrQu|H)Y0S3*29>smQI1pgZ%kn-2$JsYYlqmQe^2}^aa=8_U?RU zCK^2|D05RJ?{s|Yn2}DtBx;^=pFNY(6>4R*-KN^KbPVcBH?-(_Nt`uw^z8_bkpBM4 z<)v@ebaasDZF-A#;&ZI!$Nml_(;zSEe5dfp+!uO(vOiKHRVeA=7Rr9UwRuCqu&BAQ zF(N7i=82_8?N>9?A4Lsc+r$$e`TOM&l6l1d3;qUZ`JeCBK~s-i&`SXau@+%V^Q_07 z9~8UUB(`XZnqqfH$06e>7zGOb7y5>VhL%46L}PA_j%O1aI=j20n^n}*xX2=^TS-CT zlU1qAT9c7b+-%b&?Mv3S2$x;Ec4;N5drw?HidFS<4(=b(!XKzfQKNCN-P>{2Q#It0 zP`^-w<`NFQz80(BY$J?3fk$_$*&f~U%_eRZyo@EG2RWuGM>Hwy?X4=Eebqi;VVbCw z=H}*>OWc-9(SS5NTWHspus@`>#F{`1EHS=q*^RfbRZ$G^)v4M}5#zs~<;|B=lR1^i zBrvtom)6=1J^G5yo$gCWNPx*?$v|9Oyy~J0tt=I{hHxKV+!^utXlCT)>rZeuMfZp6D{T#tXk>g?CShd|NoIzS zmPU0}@EO8SFrr)cvyH7arHM3d2lblyTpVj|ZeAKd8zPZN=bPhKXt|taY&k7}U2een zL=Fv59qp{#kLylz#dtI|-gj`YudS`sUT(CP1E|4hbkK|&`l`SZX;a$?r+!5rKx@9T zIfl`x#~HKqI(xe><>RPi2zj}-1hMWhw6>+J@GYDZIO0)**`sm6w7SowzrVlzSyWpz z6c?ASPdBU_RHaQTjRdNexnt3&e8h(yC?ngK?VJ!B%{6{%$&2MxdZaJgt*9`ksd&8n zTymnsbmQ3O#MlI1d!_gd+TbzGYr(W;Q>7HX;WHK2IkSXJZU~W$O)x> zi^EfzHk!NqeJlz0s2!#-B@LfR6;JaSt&B9ux_Jf`vh2NAohP6|Jb(MLC89q$7#&ze z8R#aKuXiw`9tHT4R%4?%OLgA_B7f;w#psvdHWS#T`UVCD`uY+4D(dRiQZmv6S7&De zjkML3XJy{--tOi<;nNhX!1#H*(b)mUy@=0m`1!XN^!PmVROdqEZ%py8s(B0ZF}m&+ zoG(AGp{bf8l_zSzp3NpRpO>PiSDZ#y6_k{sbggnMNgCTZX1RTRePZI;J*k$~)}L@V z%IV&XeD)BvZNdSkE+R&3qnC0}!??!AMqYmYDB0g_MMzgw>~wT=U}6n!J}7=OLf*Ca z%bB7Ps9}Go-h#O;Amyx$9u7sBJnsGxn_aY*gr|vq5I|@$cPHtK9s2s{(WA9nZ*COZ zXs)$rkN#MxPEVJHI^{XtoJOs>SNl9Od(B@Fv_MEmX@dX^v_p1$ zY|nHf>v~+8LB-^H@A)71ztB(x>iMck1$SC=Z66>vmu2AZx z)~L0rMMW_E(azm%PU(SNUAX{80GB8#D@sdA>EF~p4KL}%r3}iUEu0fp?^}MZrf)U$ zpJ+juXl(V}3pQ+^E$~p_`zy{LsxnAX${5eW20JrsR1){!Sf1_7rLsz#GgBp>auzm` zXG%9FdTJ#)3*8+1E+`?6?s7?1Ql7SQf8)PAR{cV=>P~-&+Z+TM+w^+EXlR`CE@2T7 zTXsis6d!e@uTt>6+gUvISS5?iW-nDZAM^Eg&Ph+VWBk(7rcX`aaO+a}lMsjj!CX^` zd(|0BiA_`89k`0siI)Gwn(a2p$jC@84hhb;EOutB)TKmf9k0K$C$rkv2`c8`D@VL> zwl8aQ`NgR!m56FZVPQ&>R0ah>W~!+r2e`Qpc&}bWC^Jea0?6UQuylnuev%!1%e~AA zXfH>%xObM(dB;wi;Khpb9v2ewa4g#&KR28&V?|~KzB%&5*?7BiVVI2g&eL{KU+b0Dyj;zdrwR=wZ2qSX`Y$0=t%|o570O82F_44V9U1eeQ7m zs^2p@9tHH!VzVqSU4Ah5><0>T)vWYFG2bffX#U6}NzDUgUgOP~+x;nt`F~?2pCbGh zu^NhufcTa}^B?vO3{)M&I7AiGSd^-Qbs`+Gy z{gb#1-2zRgu+^nfO?jp(bPN^kbfcrf!oosAdRdVhIg0gwC2`hx@3om$JU%1j={lWG zUt5#nd=jE0z_X-%u*dU5D5hBmH=WUXe5?6$_b*h&fRi)q*ErkLxZ zH8)exUQ|Og`w_Y{lcu+L=J?;^y@lQVlL^vRGDwoTx_W9&t?6~_TsLm`vt1QmVq3rj z0sVy&PBGYSLW^wuuvpeBiS=mk%XhLsJF)3eu2lgzcI$4-VSl&4`Nn}H->VJ=dU|>x zAt8=w)SEiRzosKmbI)1Z0t2Esjtf5?_ufknvrP+=ek!!mG~By2EQ=Lwa|_Zu9 z*}(pl&Oo%$6r?uUX8n!2d&)G`9o?b~pX=!EQildw6*KlQ-IU(j@9*A0XYEA|(2Md430b`vh!Ift{TvJ*ki{8l%GL zx3b*GbL#nQbS^bb!z+k+pRg{!Ir<3eft#~n=XBeKNZagCMIex5sT8r!rnzou%{kRD0 z&0fh^G`lY=LHXNVJ7-s~v!Qk0I*}XNb7Ajfd_uGB?}?RW1r1}KZHag=ejH2;Q@hd? zlWVJj*kWE6XPg&ghTC?OCW_LH};6Pu|mNNroY_o+YhRJMTFfC`j(8=cJ1EH6=sqL^SAstfgFm+{P1exJ#-my?^L1R!RopGeW5VmeBkhcDKBFSoVwz`r&bbQFSJKv_El-Kekb55Bvl8jBtZRC};#z;JymS^M>NcSbs ztoZSmj0_o6_H2~-!eDtoLxQyOiPSf5KDx~nvDPzTJAVTn#iF$txMmrf`Po?l6khG; z`mqjjNmEl(E!qIX_=Qy!G&F>dKk)xBx_gUUw0CNYJvhQiHVx3|YbSE}J&N+}Le1Nm zqg45r8|iJP`Lf*s{{H!}ADua74I387zY@asCUre~)=v)nC`m6F-0%cnOFWbWc|$Au;G$XFA%7!ju#hBfq1n46Z{JUiAO@ zBAc6m!B^Lr^f@(ERd?mQQi&qs0e$T$7dJP)@YWoBZ*OTL$sym%pA_nxdA`3ZFiWy` zTDLpE5=T)S%AgmDtu-a&d>{lWv9z>y8RTp*d7<8I{!JA&HAxTCT={WIa!D5rLBqjv z<2(QHGqz&#gGav#{tRIaG&gPQt> zE_Y?-mUDkwi8V|nCnr}@Kwo&F35ChI)(3CB5;@Ui3w# z!~U2;yU$sSZ>)t4T|!9YwSmq~#fuj&LQ7FpJp79n^)7xCHJ*tZC>p(l2IBd^Ip}2Z zx%rF1|L6xiB8R`w)!xa6*dM87ynq4f2LP*!lhX~4zk>=So5Rqx-9qA=a`n&r|~edWE!Nn2b^TxHzU*;Dy3+6j(nAN zaXO0uAsS+scYHcleWSMeR8@7u`_<`o$>DG}gL2O0joq&%7Khj~G|4z8pfOdxEJYnG z^uxQ}Q-b&^Q-fOl$r?b?$p;{yQ<|GaG=hCWfMu|rq4E17D;sbitGv>pgP=X8LT7%`)zZSPp=$&>2z^HCkR{Gm zLK({2`lQd45aWu$=`_QmwkiBW)*MCJZO))HzU%A;3T|8e!9BWn@ePw8g74hTe*zIk zG&&oxc+s{PbsPMAvPpBfuOtLEsu) zoSvF$+Y3YLiDO5P&QPg%b(O%$`gqU#(8mW=&!tq*s|Sk%Ev@!JhrINi`;=7R2FGh- zvjS#6C1vN}aA;~q;_>6hHJ2UZb8;npDNB`cW_at8yu1n!WEyXmy4x(A z2L3lu7J0K4h*0kR!h(ZPNpEYtSNEMUN;kSL$}J(rNp<(3jsh#KJ*MtCa!Ex~O)VvV zjW;th^ZAL3##YCYlT%Vej~|za*9FESXsuNzZnkqdJvXzx@*6?5lnrI@A1>_K_xth5 z|7z60C#9(cbm=|_boTXKEAF-x5)px*5!GZZRS75qEl_`K)?tCy&n!J8Q@n1{+m5|_ zuU3hdH&s1oWvn{p2EC7wXI09jM30Az{{_-Uphwl_dBLsF2rbwQ} z9o*7#)iD`{8-tuYd4+nbq4)CNo0d8tzGgmvJEWM2cWTdsrW6f2DBfD8qYaQ8s2rji z!N725ceJ)%P*f}iGU~t$h%uf;zTW@v+=^l_7;t3gEo}VCo%s0ppmtFIAfD#cn&CAo zx`l9&OKOA`tK>11stHsG8aA{O>fFPSC$NdS7v$yR_=i6K6*ZRBWbYz$#%+*LTq4YB zsw{L8fv*OJwhA$u-9fx%WZEVysSUx9fY%Tm*_uPj%VA) z9HcmChI!NFhs!oY)k+qw!#kF3z?k_-g@FPb3$Os(6wjVMJ+v?K10k^$Ts%OS4|jH& z^*2_$KE%mkRD~Ccw?{7+xPU4@mp_*#VUUW^l#>gQ$u<>>8x=p!8DDJ7U%$_-T3cJ`$~|$PWBc6pCVt^08JusO;yK+q`l&rdroa*%O&aSLYdC0c~-OivcRohekwB zK_!+mlR0#gv)GAbXHAnFW~^*CpOXV8W=l^n6Q_qbn?F3B$?#{$W73$ppx@8p&^}xJ z?m-Dn&#XwrH1zda&V%d(F1^jOc!pa5nzistI#p!$w;06o*xK}w2IRmFrxNw;q{rf6 z>ns6OFpDB_gOQPxRrw8wYQJ34B)>z5)pYlVm`jEfVLk_LZ!b$M+YOJ>b9~L|jt4F# zKq1ru**!3*((>9RN#eOpO1XuEG`$5Ov^}Wxz`dxf$N;5H$|KuV3E%8_?KbOffi5_2 zoBw)RLY{u(-p`W&KgFG^*(}6+a#|O~I-e~ZO52WF^q6b38LkNZ?76)B5`nUth`W)o z!s>9)jc;o!a@E|pCaBQyV6Odgw(Nbu*;vYUe*r^ey zGaMr79?~E^P0X4u56BO`Pf%!0n%gH3Q(fs3XE->Tifc|giWTJzH)CKjUG@II;&S%Z zk=RB}tSN1a!8bVUADuK@#H^V7(1szCRMx;SLArIqpcV$G`}aQ>m63Y?$~I=rbaKj# zZ3!#e(!#?pmZSe0MVxn66bR{loFFy#MC>xhL_}zAdjq z!uNO1$@}^BMm>&vxnK~I^<>=2XSed{oXDd8;R47dw59pcIZBtTTaa%-PDQ?hA*bK) zG==XiP#9w<7`d7H+MFi^v0t! zkrMIX?rN*5Cb&(ni}tW)7BhGM3{H)o-N_^qf3aWH3PVx~@cQiOWmCoMZ&H^F3AFP% z_fWHEY0+{UN@ou-$Ok$k0HKdqD6PP93mv!Y*;GOXL>~AHd z;(9{Iq}4T7qDxasEN-(lTiOb9y>rdUqzf8lVcSd{(NJG-HGs+ix+e$)pnHbSq(}QN z4kX=F31e+mR%-pPdhu&eNNs6mxZpKS#^w(lcg%s=X%Aj@e}jp5RiF#wQ8hlJKg!?F zZ=t_bwt_zG-Rrte+FBGkdUVe?EFZW?sP7L$o^laHn@#tY+u(4iLXIh>Y}1Oo$t0`G zZLOe!NY>{J;xjQA<~&(-l}uD&-TzW#tq?HW@omivhPb@k-15PTFhwL#WCvFsczS*| z&OQyNFrYWE6FSypt{T~osIa0uE-Ek%L<;r9ooCs|zDHutMZ4!`?=D0dK zauHe8UQpD)@ChO#p#K63qP7IsZ~o8(-Qn|>`>z^H1JE4cm|mhvEF~o4D=RzJSl9Ey zN+wkyp#=GrDlQIASCB|-z7%_~m8pV0__KNbE(x{YOKWH7hi#XQ67{v-0#0HDBDQc| zidT*1L(2Lb34lkFv@U3J z_~{`D_E~B-N-+y4;v4&49K11j8VIpbR?tR+#5dtUR-Bq=dr{H99T=zA6?@fvp#?v zcd3)e{4NThR=}R_J)hHwMD^EA2Q+{k0H0T-$>`jWM#z{Gqy|jGgTS8yzXdY)c^C!x zGmW!>u>XFrzP`RPy97+IuxigZ?z=vMeE{rTI}VBmI2}Fd@96qs?Syw38IwO=p0bI@X?gBUN=|-4>+S_pSDkDu^F#r~-f=k1`(=Vxb=` zEk^1+c%SLxLPuay8V3g<*d)`PNb?V5)!<#A5)^s&hRmdZ^QQj3+;Srj2M6@4A_Q#% z(_w63Kvt(G{oJ?{^S|!+`s|&lgu%_5QJ1e{!A@&&&)&?;%*V&4=u7!usGtxOih|KN z`4HaC;^RBeZL9{*P=TrkNeBwS$kZR)l>vrt)#b6d$8h*WAgGGKS=Q_*0?7urpY9Yg zc(vgL&RbYmyh%xkfmM`UEQ7v8gFTOlRZk{2$7QU1nItDk9@7v8hBSOz(1XUshhV-YrlaQ#;KR-a zd*s^l`D9!jz%LM_0TDu(Luxp{=Wi~LFRSQV47NH32LlJc^`ve)JoHrS5~DFGJ!5~& zY78~MK0hN*k*@gjWOs8<_WJGHy`9-ba%U)a0! zxwyC(j}HPHMwq{&qa$!liNm|r3^AN4L&NQ`Owv-5^4VDacpbwdgkdF4Y=HTKVTLjH zLmrF4Y2BmF%z&MDac&Mszbr#6$#YceV=C+}-rC*SS%S2p@$u6qUN;3^EfF_o=j7B> zUR>~UwcrLQo%~0183G%eew-j8C7WDh^>l)X4j2&>aSxtLv1Es-PO`b}^v2ImxshQE z2%0~1wJ}M)wLa=P9E5q}UX3Cmkp*U}ec>9C7}QFgC#xgdWONb&mAN!R-e%4?jMp_O zrYt%KS3l2z?-%kPt=5i1CjVHET%Hz3{6-?-C)2tfLFApba9(ip-!Y;#^HOJZbnbY{ zA2u>6O}1s8c7cIqMrFQyA!p-(?}!w%Re_oI=%?lZXpvX1UOk~7UcKOhm1JkdGpOcn zsM`L%q1!JdkZDBI`t7=E#OB<$*hJk1Wr6LweB_`>=pSR~uSxQeI_#XKA?(M0V0rH9 zT>UvbCp){h)B_2mA7mr(dLe;8fT93~Tmb<{-QBx(0SRBZ3{DYgtCINvyu3ooO8p8& zkRzq3{Sk{MZ;RS`^&QY24@Vx|lpUQDzs4CS_pJ5HL;%}xU4m1w`|C3od;6)0x;PU_ zAOQi6FM>b}Zos0_QX1rZhHNNxp>!BicfYccOEKIDh7$j}Z(m_+V3GIa7OX0$LOwdH zGUEJEYr^?t(Wmg{*UxfetQ*|>>+Y~-UT|`ur`_K_NrOUsJ>{3J^LydV=1CwfV{z0VN{qNfO9nhYuf4kAv9n&6u~T?lv^%^D}ejmXe=Ky3+fXpPssk z0wbfexWb37^cD`jRYx=pgW=!d>$j5NXn1I$4*4l{NPgQ*UJ`z5?xo(yjs}R zx4Cs^7d%2x0P3|rZ78&&|6!E##q*v{g72XY_)2Vd@o^ex{4A}&&F*kf0VHQAij1CL zYK7za?qg|X1y;7ox~l!!mVCHprIxBdoT{b`eR-hAop=A{3W;xXa&nTBleN-#lU9JZ z8-Q_FF)Iy$4!eKwIa zhK2^XO|@7{SGEet16#3S%dB$FRf^iwzIIucz)+PQ%dXaz?&F4g=splYxpes>IWtha z;Kss1gnB5`_vbpemgQ%;BvKgk0$I}LUB@(-wvQe^4l}Sb+=lth$KT)o&Ye51u2Ptb zt*XJQt5-~|MW&zV88))at`cVB4Ls`XeUdUJk^Z1(aP^BJp~wJG;8TH!NlgVl{$ozg z6K){=Dqvf$3iPh-^dZS@R(C8e_dQu}{!+0Ti7$xUuZN#E_8N_eLoYK~ zh$qt#-oe#t5luJhzNj&71mnguaeA1>3poM^|KULLlc6MPTQ9=-*yyCEB z8`dy9%#gQUBa+FzxTmckD?yT78*VeldIPWNf9l37nV;TvmZF94QZyOxjjixzMPzt@Yq?R3wKR<61MMc9hxqVnfJ?OE@NE|n6O{~5O8w_;5Y@O zC*UO$8l=e*A1Hv>6o*`@qWG4_T9T5I?iL*79#=khszpp@KM4q6>#K*YkDVg>u46SX ztoG>JWm-r`9TK$tp~;z>=4=>lgsMoyS4QlNG?!kJHlQ@}0s*WMyaqKCgT>Mo7n`RO z1EO*H`7*Ld7x4^zF&LE&?n)1yB{2-rsrV+7@$m(j3vlwK|%I@;>d{j+N)= zTn#d~nki;(ZQa;ZLzQ<-87h%oHu@)?iE+oPX5U{ih|rS#(6 zat6ZQuCvqPOB1pr|Abd?zYgR4Tn~3e!_3k5K8A=3tLD>!H)Rv18jL49t84NY;UQmk zZ;&5wfG52r&UHDKlt5H!fRLE(6cr6#BIdE5R^$AQBf!wwS>6uqeYuWfIng}8JcW-u zh)iNy+hmR|xX~&q*4byjh=C<#B#N5^r1&g}#g8H9S?3b=MH-L76*-fG?U5)u8oL56 zoSDHu5HR3X+f7<8e)8mrky6!5;JBmDTOyHY`o#HB03o5P6*{Hcov1K-dZ4P!Es4Wb z*z*`^ef0gAZe;ITLTM3EEzR4fc=lb=`e2eYU>wF8yK)rx{``WE@)ON4L4kY( z4U{f3+2g-n%CYCJBl>^{%~_w-Cm;Kt($ zgSED{1~(_*+Fb$oxuKzgYXW2dkbmwLs02?ZJ&E^AqjxUQ23av%&hAzkb(!_jIOpI^ zLz%Gomyco-bTcRhtkG|TL|=1PE?_CMgA_QevnO%+Eu79m(#cR5m|HZU9w}V7a6wtw z{OqA!x4^I3`Fon7@bg<;VYYuEWDWjl^Y93ncC}*`q#H&=V~=lzZ}_s`@r^-Fxu0-1 z+jYr1BJ-GlR+Dvz{Tm}ln>%)PqnMDz-eMQ?+r9DVb8qVIi}mjsUmna8K-wV8EAkf7 z%nm=Z^OV|ZL^;Vo-580o`xW-#FB=-7YxFTp|z z6rAYSx6RE1nw;#kz%Qci?&S1%_sQ@soJHD^ebg)LyG387LmaAWiiZh>-m3-i-}FcO zi_GCUq(tjq1Yn8G-oZg(Zpp5V6(tWvE`cNWn6U7(TeqI(Uu!eEx?l2ER)%r*myVU; zLkYUo)?Q8vrM40_gl$I_mk)D4z&^$I@JcViZ*FAdjwV+0=+U$Bx}^i&4U;jd+DAjZ zuChE~OimApN*X-_^Q+y7-M|k3FXN?Sab22QC>lW8v=)o^5C(YWyZ( zS+DjpJC8ybJ^nps5)6`&}0?*IP z+7y(PhyA*3mh%^3>bi4h1cpT&xV(gBvz)gF07Es{u73f%Urm>vXoblF@FaJJm1KZ- z5pE$hdb-(Uo>ZU5YBI$I5&li3Dd+Ujz9XeE)u^N7FVyIIz zl(cQ)Bg)?{a%^)30@%%s_6d38UBN-FZHk|Q;UBMbrS2{vU*rbiwO7d)%yXWRkg=CK ziB=OX$kmFixl*lkkZ?bjl^u1qhdK#-qFCJC8*919ZD->8YDvF)KS&mxvh!4G5SuYw zm0Mrm5VeuES-<8mHV#3qHp}mS%iUH&8qFYccV0KK-PsHmutc_kzy#`uPD;_Sp$i?AXd~2 zVSdUyNL$A=OjdRm3pu_yC@_{1&2Ea^#r%?3F(1xZ@8+E4^wSx$@$#lagUqGyXSmr< zJ#Qps(ozx2)ysx-Ls34%P_Gr)9AibC2cz}p8!P;LrFsbAaA;b^B?? zyS4uQAx7JC@<9Zvqeryo5Aey3sNLi)y1lpe7;c8k8h%XePHhLn2i(F9B!beMD}G3? z8g>!t|?{d3VxLAPnf!!%R zxsXV1Sf2M;9^p2JWxH8Ic{HiVQHzdLJQPD z;9t_=`wSvltz`s*XJ&_V0Gy-Q(`ehXhu+19KD*|<%?a964iTh$!eklM3&7NX)>K~B z5m2t6^GPFaB}l~HQhJX4fqZ&v9Yy=pQ??#W{WO4beQ@674VS>!d=6iCTy_ka1W57# zkg%Ges^9BTnh^S4)u}Mv5?A7JTyHdv!^ELSc8Xd!rzHi_m+zWB#Y#)&ev-71@+XY_u%r| z4B{znNmbM3M$8fKX_(&Oy98{dfNh7&yAc@}G>M^Qz(BmPgH<&O+0cb(KP3_Mml{ec zA#%Jq?Zwg$-2NNt{*QJ)oRl2XWolN5};*nbplf0H|ME)+StHg*6ROn z-A}EBM#>5q+q><@?2v@Y|3WU(j7ae*`7`F=b>|YvvPbiDhr`R znlwas&hNaeva%RJ-U4utd(c?&Nwr=%Wn*Il3Ew@DU%m(g*!1>cqUk_g%5fvQ`hATlqK>(1LpsA$udGFr+ z3GsQy0B{pZ$Vze}6@xIAh=1NF+-F-n9^9_`7qxMI|8k&qZ|Q(UQTg4i5LL7nNg=?? z$usl(?F0#zb2P{Qr@i+IYck#Xhk3U%GAf8O78DSMaYmG?B7(FyFp7eRQl&;viu7JX zGBXH_prC_*)QE~m3(|X{U_c2)YNSXE7+Qc3Y62Ps+gYYehEy>p_xy+BScNAHwP_Kvm6 z>XON%5mB_ca4v0meRZh*G&XQ(A5Lljp2N5eOF4ll7uHUPX&tH2rg&lDo5SNA1%k2NVec%+UspK^#edD8G6UK zI?#)PTytb-D73lf1C=USCgS7ghYW&WUo6gRr(3!Gf3SeUV~34BIF)VLAI_oIE7JL! zP==&woHo3GiaN`ln4Cfb^1iZ= z*6^qwn8;!?9bH`vSZ~6cq|POhUBF79rmUo7Qn26;ozBwNjaRE%HDQC>;>SqnrP$VM z6Z0A-*L+xYW%M^~mv71|uB)x~7YDi}bJL-mTn8Nt9GBaNs?`Zg|aBHdk|pX?WLV|K~>Vm7n{X#SiH zC|yBo4&T#Ye=}t1e3!%aDkykiz(72*S{xNWYejg&*^rL{{1GTF>zeSeJjb%(u;Zvo zy%(3E$nK0ON@&B6@pcujtpuw1gmXW(Jw&ni%_jr{okjw-BWW3$AvIN1&;tIk-=-54 zGB<$enG!lWpki|_9vm1P%Akn`PdK&m!WUgeI@S*W1d*NrPenoSJ4s*A@ z4r*UpdwWu7w;!RTqzO>NmR9dG_7Fcr6FaPqG0iuZ_w@w_udbE7eH5#C14v{-u0qZg zz>PquCU-XQIe7JD&PGhUbqu2&c0vHxu}8=Q^tB}(ad%b}>npq*WJ?)Iv8Sjx7LThB zscY8lOQujLKs1aG4pukRMrlpMX!|UcpXA%PP(xu(7oFe^+56=mGT&)_8+7Z%X>s!V~QYI~>EPt~q77eN&E3_y8g??34@bSBGZ+JY+K^Vz` zLqnZd-g()e-_wvyPD(zO7sh)%qLq>r=cY|?m@!V5X+ zuJt>=`b6~q(IpBnQ5_gT(@XxUJ<;J-2}I~_`H&F%=i$_68jsS`+yCoy)6Y#OV9nJX zN6r+-szR6cwgeUD6Au@*mOlGQ8gq-Mv+cFE*ndczSGvDUuaQ$RiC)?L322HiT67o; z8wVx8%PMAP9XdM+z*dz>(<)NbL} zd+ucZOkZRtcY=3h5-+$Pgi^14g~{q}u^ir(VdD@G2je_~3`~AFS6o~?$^Q89;|Nn5 ztg@3k=wi^kz3%Ezg_h@2_5X|Vpkh(Np|6?+SJj}S<6wh>#1S?NolXb50SvqK_4Tl7 zL175aA}``>U!S>|So)_mdV^zT3JzXN(OCbryV-~ z5$IPhPtQ{smmjAoebG-BJd2IlmhL3umPSo2P)gr9obPZ(=JD~%*q1`d@m>KD9xH;y z{SA{NNG2>F?f&I7?|7=Q`%XO*Za&!d;SIstVm9CUnnZA3DXRtif_~6M$www7C#R>T zlBq56?yZ-erdB^y3q|3(9E$Kj9EDqqj)qw+6kbg2*u?@6Lo0s|9*3`Bbhe<*1qh6Dh(ZMt5 zX=4+3ujig38!Z|cuoDs({J$<-xUgGJUj{vmt%vUk&Q~%;cXVU~dZNim;64EBlaIUt zKW1i#byW?=rQkk`VsCg)W`wsmN;|C#iK9Z-bkYhsgaK3(?K(-*uKs{vW~~4J{HeX# zQ*UBKCj-!EnhkL|6(|}cW#U#Bpu>Pi>k2qf{U#~u7KJ*Cbw@hU(LTWTgQ?*ucz(^g zWK7)6uQjZ$cBZ#Hy!>LKR9aBLY`*RB#Lt(sI=@#Pha9&*XCkfIhJ~SFYfjjB1o%2! z${%;RndRz6tYt%g>U8azg+&mV{o$OUf_RKt5^lY$0=K#@&gweg-rAZrX9^VKPm} z^AACGYR0Z3mP9PPub$0J=nNEbRPK_5N+9xMFH#Q#yP}aWJ!m@j?%fMA6Xyr-Z`FK7 zjUBD5LYi%$62Fb9=(v1Zu4})VUbb3iDL>RC-+@q9$7`{S&}Lw@;O+g*^^+B;lYeer z_~2I^9UZ9e;B)|20jQj)1UyuI=c`Umf}t)}QR&D7mCjq%LxD*u4v&>Dm$04GRI}|m zRnW?$au<`fWwZbOTy^DD)z#z}Fh957UGW5M5D*a{dcIe?U1OBiyGFd*;^7tHu2u1f zf(@M~m9h`uiJbOph5NQ|P*F|Dzw-NAq_(!1U%OTgJno6_qE~<*1nR`*@^uFX;P(A- zI9yc|J<1Kn4(FwFt(43nO^sWb6K8RHymoX}#C+zo@a>G;?ko6@=|mx8SWXEw$XhGJ z@!u=pbwLFQX@xUI$a1Pc)W2i?poP$eA`JOLplA4(E8y*LE<=9>)?*+*4<3A+)fk-K z1KMgG;${8)`JqD#xb+EiNSfcF4##8r(gYSgWw{ICThey8y5m8&&7K392-M$vZx3dZ z6(a1p&t+I@?{#eO0WrlqVBo+;snm}3L0}TH)MDXq(#*bmG-x^}O!jOb_(?$5B~GY| zv^#&E+;(5uMsCPmDm`IWeuY8Q@Qa5Sw8s;i#*(OzKcQe(Tx03(>At*oawVGUE{$#C z7g%ry=A>bB#}cH_rlh($4iJP3u%^HV4I>Yf#p^;?Id$L1szv}E8NV2@Ox}Bq-f;k# zRHR%XM~NZY9(aV#TrwAC8KVNUHa-33E>;DiKD-&b8{U$;8eMxvZ%^siYGpa(SFSIB z@a((&8UsBLV+F+i!-ov@Y`}J8#QeU8uU3MCNn-3t-)!*3YJ%HpZ;8v9jkfo! z8PGfpSUPoR$mwK6;UgXOEsX9sHPox?;pS+jQ()aD<=JemL%?wv@9wGv**YBpUOrb= z%l}8q?r>~GLW5@p-DJD^EaDr1*a5@#?1LF4iUT!iwaF5uZ});KHzI`Z05CeC0)%vO zhrco-NTt+Pfa6D8bq&B2H*?Ch3ct-6H9Yq{Nk(Y-?n$gUwy-MLm)?mx$kQazR*Jbi zm&dOP*tw!oatYRPriD!}#d=y(3 ziT?n`M&PLG+^j7vOUilb)(C%)F!m5 zWj8cZ9{D#9?2LV&0TthXuY4d8Sb>iq%2^wC0nrOk)yRZDdHxwJIcBEt)qnlI_8i(U z7|d%QOV7&9GH09J_+sQ00D$0Zc5ZT|q=Afwf=5#CAcRoEOkYDIWa91Ja2Y3rp8vW} zN)-NG8`)Zx-O%5X^Mb&ihFBxjPn_6OI{z&RjKE!4n(;QVlR*3ScKDnFlX5Ts$Q0Jv zq1|!mx{%-lVhTFq-UERG()XOhTgK=8Ie~4;AT|bb|Bu_fbY^Fh=$|>pl3a(;DF_A)#|**n##!O8Ha%Y zjwn!n^marW#4OE2s- zgfm_6fze0tX48;B2J$E3bZ(TLon6Z%cqgrFr8)N!OGU_p(1nCakk@|w*S;=E6X@;X z32`w*IJ-a`n`eViz=mF4cUq#~{09s00xp^%T?d0r+xI~Pw{?jNf-bWVek80WknQP1 z%L&72dk{P%FoMYxd0<9hD*!g-zkC7)jw-9D=m~i%&;>a= zT(j>@kB}FW#bZxn?n{Qp{rd7HNMsyJ@x}1GSPe{AwnZlp)GI6=G>q57pgdeJGpHV` z_jA|P2k4GU608A7c2yAuLihpSm>iDB4ktacR1Eo?v`xKm_Jn?pp82r@6O$R2-JrWm zN2}rL;dB68|1C^A!1n(h@(x*vNrOHwr9OV_*QisuXJq{did0EP&otoTx(Wk55TsdF z8e+)saD>CcCfl}CrET;l4KQwX)VFbA=sVcwsWk{glP97xxo(7<4k5}p#&Six`NYxP zMzp#+de3`c)edfgQhRkdozw&)%bxwa$PBOXXXc8vj}q}d2JIpN zbNnP^pok2W&sqAMRBXDs**8F-3-cGJ<3umO#AdLqw4`KyVc`>GuNq3|?L*VYNgo>@ z<885*EKxdo(xfI!#nNa2Md9N&U>3NZj6A${#lBRXVn3Z6y*`~Vo(Gdo9p}IBM{>ua z?R$Vtu(h*o8Fu`L_#5TG5VzxQ(ESQ94Fa6^EH(eh*W>>Svihm!sE?~d;j5CZB%CkZ z`=A3vOKI~NfC{O~vMA%tuU=iXwLR1ZIf2mqd1WK+QQ~rQX=xW^Q^JNsmS6O!J!FK; z@tseQw76M%s144ljd8DLnLS#Op?`M0K(2Y|kBJWPb1Yws4>y<$lOk-&stW5>IJscl z%XF8OmIid(L3eR}{vJ|a<$<4ly_uHKhZ{v$Cpw_3DJS_9mQc^E8q$LHr>|wh`@CZ7&FJ z-zr^z^AOR@i3~MwzMQ?UE?kkE6Z&g6985KS7Bt<0SDKo?-87B&7361LHnkmI_7ei0 zkGS4IpacRUXcFOJz{%u&`}T1)wH~)h5TQz{Io%68=jrY75x8=I8D8nb!18L*^;AY* z_*}>?)*=(b_TNkls((+x`;^WJ=t*+8C+xh-+Mq;%8Lj2bgI}3*bBnN5U>COUGUzF* zn}Dj-g}_^zFPPs(N*q9%tl% z{$vIy%V706Uq#!b8|On?fD8f9Ji#Fn-aK6dij&}ox6DA8= z#D2EgOzG^*f(^0swc=2DbXNPvcTm2i+#^%>Ycw=<$8O}gj#7A%@zykqJdNv5y z1W3)Md+F8#i4YhV*yQ|l!ZPyg?^r`0%e}<3qlxt%vypy}vfd$r3}!q$@=JRZrw3Mp zZyFkMq-1xOAOiURI$i4$G6iQf77FZ%cOfbZo71)D>yM>g%I`2&LO(l}W1OD<^hEdb ze!BC)=jr&Jj;kCq+2i|7>}jMfWcavo7^HkZ?pRi;SXqIbZ8)@(5~n97CKy5BuVgi? zbif_~QQUI0=c3&KeOqw;aKcg&J|M7>;i|WfkK?Dkx3T9B-by>geQ)5_TFXuUV=R~;M6JiYiIbas zIaBf%JgK2nko1<|20vksUJ*ilPU5>}#AF^!We+ffp<@2!!&G?Xfqx7+)sEUHPNXy@0AU zjbXJcwQsgQVK!%-3Q?6ocC#G3_a9sev7vd9+>!O!S=g!ANDPLz><;wB>({TL$JxtD zKHaNr=c%$UC^na-E4klg-S6uP$vTo$D)@_a|(G z4^t(0&0DGhDxHW6%j46<87d6gsj_`J!kiT{CeO{rkx!BO+IqeBgpc}XeT(-?yz>dC zlj{jKO@Xi}p>4A1EQD?b`T%sG&_wk`8Z~6Ms-uUn{cuK6=LFfihviu3vixq^sDP+{ zV7oo`wuxd6LP!lMfG7x-Wxj*%%kF`=)p~HR!$AS$m#j``35@GWI*X7HZUNnfc8+S= zPR48p+2d%_&sK+hHfc4v!qmzmIPDJt!-e~z%*m8}l8Mc7+F)VKbQ&^mF8I1$Ymu7z z^UF?4a!dyAH{D}OO3?jA!aV^rc2d|F^dyML5*s%eM4mTyj!5Q!I`?SmZESSVU`=L= zxq#=FlbDd+eP&4^CP?jy(d@t!o`wv#CGOC2|NLu6(Tk&wlG|ZY-If^b)rpah3O`;e za}FeHR|2bLgAutUG_{V+Q7fZ0N?pl90dB^<B_W$`b13Qk>%coBGWVO><(iF&qyf$8D8SVEBSM}(XPtKRDRdYc-k7+Etj zFN=$J%?5#S>1)?^^&%c8;6j{==M!Z7h1iaB2CHjpq6D)Z6jP3T$>@6#&&G zO?5snLX22yJQ-P8)qOjWCJ#}$pZlR?=)IONA+MyQ1c-UN;i+RkBZ6B{$}mj{g@q6a zaZdrRKlxhl?jwKgmDI@i)W;D59v3v@r4GuZsz(&K z!yq^O?Blj=jjy-C<^ctp2y+}tA>=Dq+j!(^1-QU5o~aHB18{3T<^0V}2^j5)7)&NdXh=CUuMW9uHS?f}OI1O@h zz3mQt3*p$Y*8pI7}{)`N@`O5zi87ei%+mq=gqYW($*yy6*`U0pcKnc3z zqu0UZDDRM#o*o|$tsuJ;6$57^GCBVc334s}IG7d};Z1t@`H^^?^kL|BV%?-rAl?i^ z9>{LwvnH*P?e9#QNfjb=BD*MOA68VNu6FOQil`D7%gPe|xhg1%+(e zfX!L}7Xf4j9gviSg#(bYU-Q}!XglDK{`@EM|GrdiIb*NAH8#I@TGA|pv94)Ev9B%j zWnuN?ecNKfgPuPDpdX$yNPKgyK%~*`-MbMRhK$_Tup6KkHMb!%A`8koQJ7#tenXRe zXC_Hp=zL+Ld4~A(c!O;YT5FA+C@1C6um{z7%X3l$T;mM@l4v+7`Mj$84;FB+y(=|c zS#1Fk_JZ%OqeGSNbV=S_4PvU=o92&0v;n+L;Zoa8ow`{I2 z?~k^b7Y^B+>Wm&7=8Ivmghk=NZ^JiET3nXwledMeWk=SPC(zl!SDQ31uY=`i56>gv zEZrM4G`rDA5}jfyAf1D~z4Yx*0JHuVv|?k&Iy^h<(bV#`J9HE?(bpO(k6kGq9GFhM zn}(vEtfH=g^$X3>1zXU3uJoXAU@{z_Qqk-d*dt4pm>n2s6Y{Q)!9lh*?h4p29Q3er z>33jvrn8h`EPizbHp193^TGiFp7yY?d3{}nk2|HsbEm|Z7w$7l@|7B%=roGQ9g(FC zuV><_*m$dg;>q}~M*9mLQjcQLuJE3ltMGh7t=|o_pJay`aQg05iVLGY=#7HYsT|kN=8`4)F{$b6A2kVGa zmeaA};d=6>t*vd|RS*{U8ndDn>!-nY*bKVS-<%{t%$xNtIqp{rD{AgsG&Q>tmLV{7 z>&ffgEzkbZA;a+p$Q@#C;wM%iV zhT>A@`@21Eb_vzjT2V{^N~tfkSX1<6Y3Y46Z^Q})K-=V*CwxAi{Ua|MY0B3SJ#d?b zzCIR&tl=hj#Bi_Wf1`RVH9Z|v6k)-^NRtB54p)zXD&!}py;7wmPN4O?X=6hjkrs@- zGbj|k-%T|&0*6N9_V+1b18(0Md9VBvZv1$?9;@f&?R+b`EamqCO;1?cPm z4Fr76n=>PsfE%~h)lZY#EnaoTk<>W#I!3%~*fzpTJN2%wF8wCN(knW}9a2)=m-TFs z{n8H`uuMrohx<^n2pb~^+N5}(^@*YtA4Ji1dRXPCnUtGC77!MC8(cxH9`70b;>%({ zO+W?-vC{IvZKIdS5vL(z0l2Dru$F%S&i?cE{r{Rl%)^$})=tqV5blQYICtx?Zl^L3 ztN{YP$7>auPV8$|m+Z!bvXjjUNU|!J6|4T(yY6m88G$!pj7vzu`>L^$e1E2=$mxW7 zYy1XI!s^;ui0A)}%u{%QRgmE((rGk{Am)0}VH?tVaX%V6H!(dC&}4z}v;~jaZ%W`6 z+1}Mu$tfmrO-M^g=p^e(Uhr_L`|Fo4P9YZ3)j)hPq8t3fu@pzjIbrgOH)oVs?%Xeahbu{PlVJ zEAz3yAd~tO+Sqh~_lut*wG#TSLHb66O5EZh(GT!$;J}J8C|}ZPCh0zQB;aR*hns!M za6T&%AvV6pq^9xnDx`gb~`?wI?6?8wfGe$i@KrAP(%D6Hi39U_cDx_(I()Ui+1s!H3(@4&3lw{{PL8E38*YeQ=>rPc>|P~Oh*?v zVh+sc{>uQitslFP>_Sy*M-ALK99 zZwMp0YH~uhnCka$)<0CQ>@n++AggCl&UpJY@ek*qh>^t~)|Zo-2WxpZR5xcvF5nzF{SXo6m?UfVlKnyw62E-99laAsH6*-$U3WTk~fXB{s@7C+9=XbcJ&Gs!VEx{pY9ab#SA{`s!{3>E) zYNF$?ZtLge%|lVGF@BxVzid!7a9WN{2d|vg4pZgTyIe6k9~Q<}VU2V4vVU2kEn7yk zoB2ct5!!H;OCKkzPp2iZ2-Mh?TMT#VJ_;Mbzh`ZYMlO*E z;KL5v#jb+(4AgA8*&u{vvG9){KhDfl+_#g>V`O=_og(k=HU63ZjW0F*}LZ}FM%k7UT;_vwbrWde%MjQy%k|Ov`<4>T#fcZU9+KT!^T(qzfRwSf&oo& zLuBEW=aZ%hY|a}CxOM2I?YMSfUc*X=ieLT2uzxO?6Hh>3FcI+x)9jCD1I{@IvTPU3 z@`>FM$R!$LAS#C`;rn%usrowXVhM4LJl|_x#d8E{J~6m8Yq1pfs=maPa3bv1;s-?h z_HbdBSv0hapy&n3RGgv|@nIb{?j`)XdKR>>Em~P!(D}v{Ogy)Da&m$|14kp*7ZdTL zv>JC8Vd2A+{?rrz!ad67UP2JE8?7g|oA|+@^0wI;ab|PTD|`t!-t-=|+-}(&vuARQ zkpblr(9TQIds=8o=#j>@JC!8NDU9Pg)jL<=at!GozQEY$3yl%r^pC#HFsY?FI5;#- z#9C!_JY_T9bT`E8FZ3JMKEAIFx;!!q3-SVVb0rV7VS)7wpLA#(7V9RHpm82703fwk z(-QaPty=8V@Z;NuD9LZvf-G8nQ1x6%?G7E)E~Vren+49BDNStnQZ2rjW5&=p5ajdwF)AwoV(2716fIK7JuK8151j5qXb%Z~iqBiAW#vQVYq}Khwxd zHb)$L=C;RwZh?vAftukHT_8eUrpOz$W&H@+5V(H90jox_^w}r+VQ6_>>B<&_p=e5Q`{qSk3PwRicaeulIc*bALbR%$!) z%^I*%)WB!cMkKZGx4;C){d!73WCajV>cx629L6_>fc3HrLU(X{m>WDBJz zMnMx?U|n+$r4gn4wu`e3=-!3QmC1$Fr>Xb6XGH8uBssOqQxaHP-_b!(H4WtEj_ym;wO&)B2z54Z=Nkgu*L)~6^2C>P1=1cz+L66@<>&byaYJfz3U(w6Hf4|Z=&!RU+YM#!Tl?;B0Jb>1ox3yxI(?}gq^m-K z{{a+HbF1n222OoA@9#|;WVdUIZisw)<1lh)@z)U#_HZR7MD6F6J%)xQVwVCy<#74~ zg4wOEzTMdV2`yYMGs539&QF%Wc-%a{0c{%l(wwx^#2XHWv!9~dnqhd&(auh>v~ClY zMUzIR?b%BfpDrJFw5!i>lUF1}ABwCIIVQt|m4TcKSS} z_{_6n$q*-bjF-Ge980x!Vm@JMnDDNGZofOWs?H^n3~JMOqPY~wug2BPkwZXX7Wp`* zN+0)6g3Zg5*L}Zq&ad>Z7_z;>Y~;cXDai;xs3{KQtbfeTH+~b+yJT+y6$wo}TT9Fd zRQq2o`(nSo0+(M&iL!C5O={YK1GpP(*3k2G%~P4^Y~Hh7q_utpVF6lq%M9nfN?q=v zg!PE!tpJuxPHl81jKIjITgOX!TI|{3sZ(4V|K@GW>!~?Cid@-s%&m!(-nHn66?W!uc{kn+=t6~^oVvS(5V}?iQ`e2N_y7r>eKo`OGWWc z)%^FON}^fT*wFmm`XIENU|?EQK&h0Jb1cP&4eG7tcB7A(q<&qs;=qH{T}w5doZJhU zgMsCtn1XJPUtHSqUNtxK!3hDQuKaH60UMcFUY|!fuHi)QHRA0TC%$KWlr^|TGfq^l zQl7_$M<^OQg6Z+w)P3y(z_jtW;Fu7J7TB(|RO z*+LQ(_GEn#dOzM=57w7Hj}{YRb%_N)6^A+gl?8AcVvvUhfKb1`FGJn^f>Aokha(mR z2qq+szfB;=#vV(B`?Kj*VDH2*=Yek zZ<{K$)nVXcUr64vcb;X_tMCT-{-#DZ;Y{BLOXsHRcDcA3|2jwv>CRpAAk4@?0xH9HJY~5o1GlK zM~T^<$+==uq7)maGFjf>xcB5`^umqGTs-Tzm>Za7v2Bta(Fz$r`k*M6ep+ zg6RC{ek0MeNI}$W5BlUb)s5FSfzpX$^W4c9W=#3$IQ+Sn#blR&wecs&F?JRTEUVD`Xb4kbT~dlB--0yLW6t1(jw9+5a6%xondDCxGR5AU%vP7#F)fA$QDP9=56#`_f%tk^vDu@dvitcG4=Eqk@Lxs2akL(Q9Szo)Z}x z-dYRWBy>N3a2g!p5#i$CX#)t+@QncNBbm3Tvmr&7G<<0+=Q_>RWVc%OkOfaU&NPr zZqmhFb3idULW_!sxJbxbftc_+W%SWYrQZIhNk3iw&UGCqeaN9`hs?583rH!r&heJ_ zjguq**;FH?XSfXkMX!U%#NQBbrGtkKL3)Uzg9B!+umD7KV*miFt%!a5ibNuT{$!l< z63+1im66G_ux8nugkln_6t}diFxe=-FQb0?S*mn&TnbpF;<^ti-g=kgjQuVL#vC!S z`ssH6A~%VT@UDz{++N>ek~IP{p{~qNIuubKt?6mNVW?-{l3{X!$48vdxL52{s2}ZM(?KM zr?}{?MWXgn?E3&&anqHAf=r4XxVg$7;j{7G)1XkKZ0s)uf+c+E^JFqg_9?xybDqCJ zYG`ggnk)A&CWMc+?m&mrW0!^|VgUDB)e?$4aZDP2I4w=@+UWxgOW@A}vrq70cFSNmk5P69-?N5&M~k2f`vFb(e8DyL zI}y#K%XxLAt7GMx%;Qe$ocrz;rKO`Rl29-;m7wG_1lyy(e{bphtJS4e+;oEb^v)o8 zg$oqitW8dXw?#fQ{-B`s;Ob{V%+D+Z{R1uG^fw(Gx=&D4BZVcP)d)u5X75m=1xKMb zlT>u9Onh>?R z@pUz@VrSLIvrZDXz19#H6MHMUoc?smd^T!xD|&jjP#fOiSRIlN7^V2Of=YB-py`h; zb4_n5RSbLY^OM0F$Nr$Lsi}2l$kKMWI#RO}2Ki34hbD{2xvGG6hGl! zDLJJsO8P|$@@(l4q^9I|^3Zvk@sMD+iv*Y!kff!OD{9a1Jq%y4Gv&f;))sAQgrAHy zg+T{3tJ2!0pPDPuqJk@eAwXwYLv7g*+98MNQ8BrZ;KZTH+f8CYOgBbfL@UOLXr5XXbsTg$?C4Gs&B0?HjY<`Gkj8A*M?g-^AlgjKA1a(G5Xy}>l79*%;s_w zZEHM?e7mdwnjzV@_N}0nM$&rchZ(MEAmn8(EQqJ74~#yDgFL{Av+Uc3ijGH0kwg3r zGHd(+PL5HZTDTn;52NL;mpC+uKE^zhg_)*gIXUJKlG0c)w%QuFiR5@x zpzrckUAE&Vlzm#ma_ux3Z$0M1)UVeLlzw_S<`p@ff2KLZKA@Q&W5}`e%d;Jx?l2MP- zSt6N!F?xnsHIl?K)#7lZ>OhN#2A9Cww*sA?STj%BTb7AN_69-#c!7LZprkxTn=ah{Lj)g+i;1Hh0Ch*)Pg&ywCR1M8PaRGu= zNh85&+4TMCeg>JBQWuKrSLQMh{NP61y3`fvE3z$24cZ2Thu*XKy-jnoDbipjm+>`^ zj`%fD5Wsd1m-s3s-ks%@y_8sbu+mR$rX#hvqe_^-o$|-Ql#!kgR`rm)ok#y2pP*A*n-`1P_ zvE?13N{w3w(l?Gr22pg1`>Snm-ND+($r=uF;x9snVtE)8*dYH;QGzzJp`ihykPHxv3fwOTrKJ-K zs=d9rnBkOb@^g57u+_1et7qQ;KSE8_xW~AVn3Ntr5mM zHwkpt_b}KuKw|UOmLU^yiGi#!oCbg4fdj-tw{FEr?ClXVT_5#50Q=*0Eoh7$T$c|( zFdWca0O$bRHyIMo!L9_ZNC+f_pH{Y(R@A`(w8{r0QnNY!l+o!n0p zF4M#XedYo3>3EdxX6({*!fjwb0CvueO{pCm+geNla(ZZUtpV<-d0=a}DK=TY-=+s< zK_ku`0sMv@jAaR zO}J%o6So9f0(6AnSaZGSv`YY1S#z~JNw}8VAb0WnY&FU}oXP2=M+44L7G48Kn65Mt z5i4O7o)ZLS`ryKk_|l8vK$z#4KaPLE;0u&?%zey7d>#r$4X0+4El3OFwG#$~mI(a*>h=5Q_y51211DEI zsD|_I8TYSmb_nrbBL#{ER##!XrGcO4r{;AHfia+Yb-%)_3qR*vY|D_fBl0ui#sBB` zKhNQxb@0zV_`l^GI0wDsCFm}YgX!)+zyEm-|Ez<5_Q5~r!2f6Gz>?6tefKToG~Uj& zr%fs$vc?-G|307X!TSq8AAfj){Cb@x0>W_-k(zIgA-`Vz^6v>n<-fibSMMF*tNrIc u{lEJ$A=ktRg~VH>s_;LyBh&ge5tk2wzKcg?4iaTpb3W>iq>IH-sSD7_|*s3=tdL28uVgY=T3;|QY&s0fjkh%_Tj zx*-ri1jGPAdT2rEC4?RbDesQY|Gek@a@P6!ei+tr1sCL(z3+XO>$>iH^Z15^vFHKG z0}u#A^!l~SRuG6V_;%mK&wIfOt>nuj1hVIxkCD+0I0O=znexogROruNV&#l3_G!F% z^Qa}or|+aI^v1!%n%-$=E|VzJKfC^xM0vdB!~Jz2{I7(c2?0g_QTkOX=Fj&D%(0^q z&yJ+s4acD(hQ^NnqkJO1;Pu$*s0q=#&%z?1-SYI*gEZU0yOo3mmU0ow`7ScUcgWdv z$MG96+R0Aq(|YH#QdCJCa&kRoHuAe8ek{l2I_Gx_t+)@+PpOmaP3HIF@Wq}dJoAss z(sn6+PdvUjea@(SZ=dr;*Xz}rBQZxlSCrJr({JRqUsHV6uX5$_AG*c5TX|Dqsh-+* zZf&oN;FnO1;h6Ks46Y2{m0PYj&pqbTZvM)=aosfcih8)&ph)hAKRhMAdHq>D+B7QB zv5~97Zj+DJBKX`;{^1o8{y`sF`uA~To9qF%|&I?wGa z^zNRm%3U5H50xvAlofxXNh_Q<;eP7m($sO4m0#}sEOJs}ultL%2gjGqV>>)zXWJVa zMOddtA2f;z?K!=_sr_*Bl1)PEv;R2F7=F$7VYURfx!*g}|!c+^W!QWKjYB zogSiJcZ?e+i{jeWo6mM-QNT$;Ab&Jh8EHJ~rX8G40fE)Z9CI4mzK|1(1EB;$7fE)Z%+@zzzNd2mgWt;0FJKgMYyR zaD#ur!M{vW;0FH(a4_)e^DF;L3;4Hi{98DHr~F$u{{LAxTyC(Cch%%GI|sF$Feta5 zAdvUlFV!HC*|}_@7eZ4$Glr~_0>ra(CZ6~AuKZQYtL@34Pa*I(wwBU{E3}0mklUG@ z!~e=@|JKBRBm93y_=LmY(9n=ZvCY|O<9GcFeSLjXQ|@es!v6Bv>$T{RXl!dMl5}_Y zBmIJC7tELKz`vja4 z?<&Dg%R;!D65ST+8cnFG3L7}=n4h;gIXOxA+Y>yY@7IfaK8WY#4vmjP{o*rEc(lEg zFg7-}CUvO5qjN$Zy;Lp8&tFr_#2R?C>gRu=ef@gxUS9onLQllYaSwn0rKrp*&>0D; zI(%_Ywn3GuD6k`$jL9e%2_j zs8D~@Ldk;LyASl8QnkQOJ4C%lA}jTV9enM_M+fZ}>b7Ni!)rf2J>1*&>eZ`jgB@P) z#cP_qdaccSAJhLVF>lCHMf#l~A?Is)pKE%7y~L+L4m2M0nx%c6FU5xS?0iZ!#8^3I z-h@$ch9nH1H`$g{F!8GPF4QYzXKi|Mu#~yDs1%#`OWMcp9)`XlC2XQw=*X-kicQEi zK7DrfU6rrVK=){0SxE>*3fr)LbSy%m*}=iVGgC)L=M{9JJz2pTQrz#Wh{~^-V=^t} z!rEV)^0}z@iu`D+x_SfUvMg`jP#2;@LQK%uIZt$n2cU!;8?9=ATcj;+fy&X-QE3x_N#&lD{_ar$G!Jf zg~;!7a&*`>&9`bB-LRli1B`y-K?yAYDg}?|%)c>qajI__@?(FTQA&jrQEu=D8xa z>fSLQ63#=nhTIfox-@leecV`>o14pu7^e+{^JFBUa&nmMZ*i8HIu{0`>g(&3>`ICn0x&9AWbXV? z4g(w@$L~X3!TUXpx0IQ$h|Z+&PHWgfMSbY%r^78T+ik>DiLy7f#*SCa|Lp*iTR&{f zk0i5qijw7bp|I_Umb$N@&o}nW5b%BjMNvBx_;~+jK!3TDl3cpZcHdA5_wer3ck-L6 z(_+V*uPK90&d-f7G~|PAuDE?ArNWE4OsnvEpdbrHZg*!J`3;tIMr34UP^Q-mCVNR! zQDtcMY~t4dmKa*g@f9z%za>0+5qy{=ZE&zAY;k~{9@Q@VbcVX${FL^s;aDHq4ry1_ zISiP#J#I~>>I5xbCKuc5i zx@v5!udiP*KO2+pq(jxav?GVdPTkJ{39oK>1dPo5Y)0Vs?n7 zSa^qCQC^j%euew4w&gU5l~o_Xyb)b$*Qpn2PiZWsjE4tRvhIYxTMjC=BADGORP2?N zN=KQW1bDD83xu{SD|`O+^jr>Fb;3t!w8NK%$xTq}J9}l_(P* zhk#)gk{V!WXh_>=Hl(u$@C~!Lm?w8jqdNV12JI|Or4YzT8O7=u?Yh~dJu-wuPK|!U z?UJV!{ath&`cStGvg1m&6w=*lI~5e0$d{Ytny zKRvtbkdOePoXh2!VpReV2uqS;iiMfk(nt*=GIBdVBTz47rek}1TNt+8 zWDqg48PY112TAYo8vdMAsa=VgicP}yR7sgs`3970>FQ}lBiDv-Wiq5#IU1}S@d&$ghZebysy9mec^=6CAqmtxcX!gc@C~bLpnan$Q@RVq3lBG>oC$GLQ zeZ2g8Tl|<&+#$wLSx$JGHCzx%z92^9#~zAV$e@GOc1wz#bXONhs-ycF&|J2SkgCap zyyaz0$7-jV=*=E>j^e}o(n8zfFI|@H(yDfPn9X_&)TWw#wwmJajXgC^SCXAqM})j z)uYjq357Ea*CHZDIvvYT877EfLfl`Q5Vm7$c>D%!)Ca!SOyG9xSH%a)Af8L-l8V5R56Rb%WY`;Yxq^uM%#06!eQ z;r(d^NM&X#h`sG6Q$^!HDXE9?Df<=DEZQ(0MA>aBJK)0TUSVdUtr^>?>Sd*;<; zJJZZzT9r9D@|X>;`ru*%`wag;Z=b;JYNp40PwVhbqM~NhC9jRCbILH#k7B2-*6UO0*8HjYFFVXdhAP_h-j_1BwsPH#O_W{I?J*?k zy1@Cxl|inQ7^WKrpXIjmj^-}Cx^&mt+S<)cF<>TMA}{y$xj$Y$xoc`_>Z0K2=*X;F z?^3`;x_W!hRgVM~mg0SezBR{LUocb63$b=LD+PyM>{4HQ`)8>0$pEVNYl%T>^Wivh z=*+t1A;X|*gWJWo(oKpfj)Qf%v;Fju-5Ff#sg}I*`GGVGOJ)jm<(59cx`S297dy^rL3t6=1AxLJT`y zc&%yv`+Q8h_yn9;0}4UVm7fK`fV?%8wzQLKb>~3(73m`E!rf?sZL!5m3c)|$-M+G` zy1Kg9u|`lp=X+*l*=eOmG$sRAkNg4RkQ~EW?#$4JW4N19s}{Ru733IJwC~7;PVFS)(4Qg z@W~&$*(ZQGL8;Z5{Fs7@kQv4|P~Lh5HS0TR5huf_$h_B48ZS+J%uG!wLzPJ9oGCx} z?(m`utJZa>#F;c>uYhuO{obyCmzI>QolMkUi|Oed4DAdiW>)uJy_XYBwB^BBd`prA z230~$R-41y)2~eTY&qGrn4WHSoU10!`&A$s*g zodxmXVPQv&q2Y5mNh0IP%8mMi$ozj)U~p;@L+HE=JO6 zs+h@+dgC@$YC)@{q)CNV)7I9O7;-`g6v-Oi41zZ!iik8q2Ze@);)r$mOCrVJF>tJ^`$<Sr?PW3PFesGVSW{wKNRSfWqk($L z4rbGuES1mao}T_PH*0}nOW1OK$&MYiGRKY$kx?=1XBD^LCJ?d$e} zjr6%GkT;lZcr-s&Ow54vvlY&Ez3qP2?QC>!zBwgT*;LiJF4zzPxd_Pjw*9EQ|pc*6|kq=%D$^JWH-nnvzsmQ&Ur2tp(K&QJ-$eF;4QtPJViP*v-vtCmiaf zPKVm+M!UOvh3k^Tmf`un$r4cC=10N9U%%cocpGXvFC>w+ncwB;Al1N7eEYCD23F0F zpi~8uGoa&%GWyH)tZ`M$4u#H{(N2=qwc>A3Pno9n<$Hkh{RT3RQ;HS&Ty=vkX96zA zp%>(TS;wp8?AOxNJlY?OMAo4%upYg&IZLm$;#6z~(YJ8~f=Re;tbzsZmBDjFLDI{( z&y`=H%r6#T8P>K~O;rS9F8#v9O81r}e!I{$;?AAl6JQa`qYs3HWg{mZV0N$=-KaHU zTXcR6DKab)AVvrf?n|JeqM|LGOhs?OsX3RUXBINuBm$c~No5-ERnV2(F?>*g{U99;m3PjrFuo<&qEMj~6G-i~xAF zX>DT7t>g1{0q3)kZkIcn4gkp~0j=Lu^b9=B%Hpm$MUbfr$itbE?aXc987&_d=}m{v zFTz@d#0;JxG_*Cmu!{Bf+?qqQCkNr-{9i-7v@T^EvLoil0e zR&glC*FUCgj7HO)?C-l+gP^;Na+a?GtNt!q*eDdVd3|1u{_35%tPDM-ebqF>ZnTQS zKrHTNm$WV)SDjuzV-2ZaEFm3|HTVE(NXTrL$H8X{UUq|z+5!iBtH4iG=;M9iC`VIj zWYnJ$?VB}-bEyTE5|o0w78Vxy$MmT70CpWBOHzq_@w+}CF_Xj)s|vm=r|Jhx#viOh74W|e;t^#zxgL# zS+c7tQLd&;(?+I2;g3I+JrgD3J-C@w!{1@#{Y0e-aP{dQ3w$F|{n9}{BSztiQN^JV zV7#aQccBG-tgm}z9mZc51f~7xH>i=db#J`O#v9iwo4!@)syLk1+V$5$ok|$j%~H+| zkDupI|4DQ*K-me^Ar9(VYIVNc%H9w52Hf>!s_00($vJVD#0#oD7_+o#fGMAHav}z84ITOW)#C z9HxQtu+3|t4j8vP*ecn@vNEH*skAtzGy4hLX`Sa`K8uf_b$72MM$}D$qOEv3Oni8F zIFQ7sPEoQGH+~}3-+Q&}T+d=Enr;)YRw~B7OH|VvjQBbRU5Q_WejsvN=d2<$Q zAO#3bL7QorndihWVz@mu20GhG{RF@d)98E*o2F97($2@#{jNiRz7X`}U!~MRR_0DK7)QPG=AeID- z*AXM*r%Dr%uFTIho?M(sUaOq!c3R&*>tm?htiULvZ8*T&`^-#2pf73pp%f4S zzE4cbCrQ7Gj4sj}7#pe}Fk(kYE>3u8MJ+oDimI;6W!0~?N?B&;Uq4QkspAt?P)_)dvF$pwcfcI%{K~(5_S^oQj^b(W=?lzkZ4-h-Pu| z;C*+Vo5-B6BHH}sSdf=c>1gn-+=`HU42xiCs6+v*QMybf>O}YrI=5^IbkrB|zxMC; zv^SJ`NR&98O;K@qVkmQ5N-CoYJ=0_Ab1`5~Qfr6TjeMq3!;BM^Ten@b*7xah^JVMx zj!VPcH%wr0aPo5V=H~hmtwKLyIdjohO3VLtx%+jpGt^lZNpBJX#pD-tAe@9+{bJS< zQbNEIj^%%RMb38}qS4aiq`hUgvle+1y$ZYqP;vGkl`) z6K}Ivs$%}<4CR=ZN4gyBpw(Ja#Xq_U_ob740v;vB!sS$-rY zL)D-!7zpEUf>lV;RSSK*$EYYsXMRD5CpEQg*rOx@s;k^kXo$$7)|+OT;%+@PBWw7kkS}OO9UX4fpKCiI##f}^dVx4;o^g4Q12L=WJ?%deg$`4tjMlZmYbaz^QIF~et z#VcOH!NIKMvE403Mp<_^kGpH#F!JHvHM+!#_4df|q^ zJDvmY_^zg!j=OqDM5K;Y&&&!7`tp~uxpG4t zPl$9Cv%*Y5yS8;dJebx+>mRNuW7HydNyK&TqvsOT<2}^(MB)x{WMoz=+#flJ2!QQQ zqzPi@3>9S31BLiP9bH{rOYXVcJw8LA6A@Uq+Ip+d)&m>t)@I&NHZPBG-l-5x#`5Bs zDU|Wi(QYRsZfU`WU=te`*VfkNrJPF()y+yz*V~F{>l1JtEfA9*J~}u$#xnrLg9FHH zlGn322!~^GZ3`0;6Z03a!*DXr%FfR2o;8w?z2PRr_uOsPXKiBY2Q+5Ouhq-=(TM_~ zE|jqJE(QI2iqDSkT1&v%ys*pJ0c>esG<+dH(@PyZs8-#9onRu7shqEcE%#6-A^8ZL=HI|N7a z<`g{Z%j%2sW+evDuPge}m^1MWUUm)0oo&||^cO}xd@OQ)9ma_S=M>a0P&{8OV3qaI zob9K#l05r+dn=;WXIvwYNX}ygoDOn(<>_65GcGIDr55P;*mL$TkI#0QeIp2fly41Ozfs~dkr+d#L1zzzq9RCowptc z%U(OBL08s%iH@8mjY#URjm5^sE?4ukOLzoK%}&zd;;exswQd-tP0>?cJCzNds^a+* z0Et$-J71i96S2|_?(Hu?yGfihQXQaw@B61yR7n{b6lb-KS-V)maoO&a(Iz8$jqxf> zAJwW-ALj<(Nv{1lP7CksHhW>SSTgC+xnD44?d|Ps$9X|fI3R#{$pT>085$I{0-8++ z4OaUI)>MOav$DXR1Q~6_d~Fy2T+Kg~;AtCQEEGKk3T7W2JAVAJ-YIcFLV$uiVe+ma zkNu+eH|vVU#hAeMHSR78J|IIQqy`RGDq7Any#%GRdZlWEh(Bqb$fWxHzDA?b*MN;?&-@ztw9 zN_U3>YIg@@9`M0pi9nzR-P84=dVr3t&ZTQyP$qS-Oj0Te3KZz(M0Lb#@hz~?pF2bV z70f*%7k~XVgTau}4$Sll;>s+}7cqZ#>bl)LtN-D!w4hND51jF+E$n&7`b;NybV!1< zjEr)sg0HXd7yL|=eR>Aok>Sk#4+Qc-T)=SeZdRyS4~zmx;v}lkPd%()F*Onp5CA@c zHtp^2e@^xU=ngb$UIJD znVs+|2TdVB?3DXtBN=|Kn%{Cv&Il7+Afhw`Z^QpW0@Ha_U-Iw*IR2~x?a%K)KpVbbIaa3ZgsR+E(nNRCGZAl23ej=BI%#%0^9@a z)9h)IoKDc|yu4HJP4AW`M{CBy9WQVzoK#4B8L)cEyTEbduT?pKJq7st`v(M=!@XpZ zhdq2>?-UYZ#?iYB@Y#WWev9+-?T2TbBgSrtSX>G7_I?Wli^w z02Aqe^uK>Qeb@jGs3Zs|^qi607q9#IYDGejYXY7UrXx`q~c+6szw`;46JAqhTk*I8pk#)cmz4uYhN@d{!`) zxHKk^U|#z*kOd8??SYjln{~ z9dQz=Eu8y}Oh2xQ&zk*9@hNZm0l&B`dp^vxK^4|BFrcNT_5#=~Nc232%`3S?BC(CTAtmTAE}L&+X6H}w8?Wo2a_jHa%S z*HPMSWyz)w!E9v~0~hS+wfDk$hkGjctmW}ueyD#WYp*KQ15SrodxM6)Yde9qW*QnxsoQqGQ(i#1fPdc{in(|H?VBjXJ7byJjep z*X1959H?QSO?V%}+w*?TUk&}v-3-u6^-Y#hfOLKF9Hs>S+bTRRGqraAG@h)eX@=GG#Mly2lFX&0yd}K@z1x$34+FVh;N1a1 z2Ms`7Ew(kiqDFTatVP@J)|%qfZp!Q~8eu{szu(Y{@b!IXbGF#Wao35<2`9vq1UL?k z_Pw||ke`s?%)S}}?D$b&n~zU6{{j7BsZ{>NB@10WUI$!;le5~s3JR6~-L9mfe$dm8 z9~V@Csz&7%LB%*&-ausSGGV(^!2kSt*3s$F^z$n@j84X;uq7Uf;%u)1y)5rw{wS@# zzaLB}xVAj8n*YdW_?<}^9vQI~+Ju11O85N)xap`ZUfxtRf!|pxh6-I1QZU}?)Fhu! zst@R=1~Q_r-<#@4QwQA3Abe@)X*m%napU9T8G*GvckWDl+%(Mt%5X#zI5j7ILDKo& zGuX$0- z9g6dR(z|fs!r*<^>#V7jZ_R*zMsIi%ow{n`eR9ESKb;1t??az&)l0=VfU8?uPb(<& z2EKdaBhLVsy!u$`AM+mzFyaLwqN39yqP<|C0rDmUIAOenNiqO1ot>SuP%-mwYZFZm zm6er&w0xz??D>ZNhd30F%6ZP7&N{N-(60$X0NZCcGCcfb?4=?gdWa9-V*S}T7V@Y( zc;xNh1(fGx9u@iBtKOj)XzeMM2bB+1S5|6hYTi1VvJ~7`aO=N?ai5@T-tv@fUu>Hs zqaO|$b6|`nzdLQOH#iC~B18mObHn#ZHW*h>u*7A~{sj&KXtK@?XcPeclJK)BJ&IAL zVSIgHUje|G*jMF+*u<%J@RqvR?6fa4TZ|Fa<&2SPX2W7i&GZN;_c*2d7G^dUc<9dX zAbD#h1Az-`?C$QlsO`1@x|A#UjaOQsyo$?6_4?IWEhjbwe(}53Sgm1MPLqN2&nsjz2Loka*h~A zFvqz)Sh5D4@n6MiG709Qf~}ZmM^Th(`jTL_y6nvgI1RcsMEi1Q*tuskB4k!3LOF_Y zas7*{nXz%4Ap8Czn1};t)Ny^N%x%5Y@1;5Yci8#!?*acoMs%iYf(|+eZnG0%0-56d z*M-jtN!$Wq3tsQk)ON@qHJqu(-K5HJE3m2;Zv7{4Mv&yxArpKrCUtU2=b#Xs)HNJa<6x!g%gC7CN73?fw9@@wTEHFk zpNxJsJ-`2%NW_aEBQ+8G(|1+e$ECJK9uS%CYC_2oYV985A}5n$l25dYo^1U4naJPD zzqFp#(mKYhcBeTMS2>PZm4Xa34PAIaX;UWjGmecGaJXo8uT4MUU5Eoar&x|^Ee6S@ zypVnV6NIi$9NmFm-Hhe}8OoHHYjbuiglY(Yv`Hce)|0swgZbhF{NtcYI?9=xzBU~N z0cvsvVTSWc>i}=;_YHeqfKfTGuD;GdU}C`h*J|BnYt#l_XD1F@j4umv(9xv@`1m{q z_Pizmd|}qU-X%#}Z9NrzH_s|+>bdbD8NDOrKEdDGiH9Tbyq+G!Zx)BJpGJsIf%bCX zP4`nQLGVPS{-?WJ4p{Py$CobCaHc_w{0Nm8so}>HC^GTi-Ct%Iha=`hb5$* z+4$05Xs0b36}rIlPLot!Jtim$FTp%$Bn||+dyKcYxBdR|($dnx!oqvwY#Q;K;MXqf z18L-Uk01M6J$tnbhr@vezv}A8XC|PPb{y2k#yk)&Md$2XqNc%UBR100zylif#Jk&i zEyLlc%Ya8GMd-bJ{_EmULGo&{D>`C_^@17rk`U< zaaZHEh#E2UwJQVRj?!m5pGY}qw^2`YdR8QeodgpYzwZN!vCRY`jufJ96{J7mL(;D@ zGyRc@8ehm{GVpLM6>Oi5|N0Eh4)yJmoO9@jZU57bsB%WwL;h4-1iv&p#$}_}$N|i9 zwmtxs%8dn4V{$=Vy?08IkeInE`zZbYN5G;1OYj%y!zKGpn@)W<;r5xh{$I1do0lC9utrY+VaW%eXIT z((SYBsCAXF_QRG2U+Hu@t^b0Ser@eE=T&!&7-#gbwOCdr(m*%BdgLf`l*3alcFf7! zFB2XZSYmVb3OITVUEnWAL2Us&FEE7>yFq-JOsXZ3by*Sc5f+iS_NmmLZ<*m(^Yb)wgYL)JB8?X0n^MJ2{Eh+k(?c zn?}SAB0NCoQ4^+uAusUrq)g&^ffk3-$_9C}y*`GOn2?}3E=Wq>CK?+X!E}XfvBOb0 z5mAl0n?$EX@R8mQpt>mCJd?CKN4arHef>M*ms8E*Dp;xFOkR@kI3{}GPH=n9WCVY; zjJ`|TWIm1EFAXgNwY^WQtfD!~+utDnw=@&x#0pWa_n7BM%AH!vDt_t{# zNnY^Rb>NEL0trLO;Ws%WmRahx18DvnLVJKZ#AwDGCpWmwIj`z;2L-rsM*&NgPcryT z`JIyrJ%WxpQL)8hwHbfceEtVmZo0MB-us@2iH((sFWg^a?M_%KxO8On-}$!)0W|N38E4wH60YK_C|^)>QQ%=|+>~*WiX+yPHO% zP7_~ONf7Z5|+V#5qR*ySkl#>!%y3?G1gW73RL6=BBGq z=p_q{F`gmNgQtUcK&Sp|u(W>oIhy!Iu+?-dnkj+kp7#h@ka7GYt=gXr$2`h_d~vF%Etu^0Y*WmvCS7VmJ01)5?oKkEDGCb<=LlpV zlj_--86Zdkf!k<9q~^=?+KK4cW9igcBQennl@&QTH_luJ`zbO2zR;QNP<-0@YpH(& z+XIDg;Bd@T*c(Ea7fWE8I$%DboJ5KNI>}eg6l;Z?jaESioX!xP{rfjx4KT{qXfjFn zfoLSOwv5tsw@Z|w`Cq@!!p=vJOpUtN$)wMu*tr~6{u7FCm`V4$v}ynv!9TwSdAw79FvX9p=Y zcy4P=7m|C~2Se0LZgCBt zo?4oj2K^#`rxuRBMO`H8uxY5ntgfzv!G$Wbmx@y(+Tl(+Y)u%uyu|Kl=u>Ik;FCD2 z?9wQ)0$7YWUJu~k!DZ=XU4TEpd^Hf5AB_P()XLr&Ye!(dZ?|17IHc{O=7{^?x3E$Z z$DbMQFXhp~n9E(8pV|OU1z5jWMhy7aZvb>?B;Pn%P*7lW_ogJLP20hAIf_g;fki_P+MDYm6%>aNFX*L^F5 za~Hrkn2hqD#SZ4;A*zEEybiC70#a4kV`G^9npb;Z+3lzQ@7fAY^3-&7kDCC75h(3< z{GIoZ=)5|TIvxD2{~6RM(ExCd&#wWBad5vgLoAehv%v6bD4QVS<4LF~jM-rRWB^4# z?27)w3jpC&*p&hDLxg&o8EDO_{T%{^NRtfKuGPsLigjQYBT$7E6%{~t14mL^tSl{Z zq6#oy`-;=j`fFCdMW%v4gHR7zPwcsta^rk5 z1IWeH&Q9gkk2g%g{6tTrd$o&-9k%|(ZW{5qyQ5%8^T!2;T$6GQB+&^9kVddd0vJEq zdg^$EX68w2I1_T*PYz_HqTfOI&p%I-Kv zSohuX_&%`um3lzfp2;RycbVo{Rhp%k>29j?q++*(Pv<_ zxCroMryjAATFs13WiBTyi&S01_@~B*i>%G_1i7swFE6jYz8G*zLyo1RW7%!90Y8x4 zCSpFFU4i5pPrh6SUgn*)wu6*D)r|VyluH^esSV#OWXkOfTd9Z4H7@*09-tlo+=Ak1$p#w&Yp}^~Fk~m58w6Cp&YH3TgUCs!ZNaM*NswH|YSwDQ~!+bs% z16!OP0j+FDFEaW~WrI`Je!H+E(GlS;tcQhxWBps94gr3C891DUqzYI9KL{KY!HQt> zLLo9XKWaL~4!S+@5Lgz+Z&P`jS8 zhU(fcC`SgX{3F51kBo$$q;j9m3_`(`8Sf%IJ&UuYkcjaQT?m@g?@CozPS@iwzBLfR zA!5SENTd+M)zur$Aj?U~c?#4X*x#VG4g-Psq>~`4=XMyzY-vCTLLRu52_~XI=9>Dv zwB)aW{}V)A^1au8##D1XoK?l(*xFkkPCD)8z}PwM&r6@8zV%x2u)bBuuj_tQ3tV~l z?v44z7;Q}C>+`?J0a;y?p($`Q;sltd%r*CLDJKE(2uM7Hni|$eb@jB4iHV6$ZF(J; zg`b)z<$NivTTMVbtuZpl0vNk)yE;G;_s5fK;+|F83U+S)Ak zU;|HHZs&6n=W`PB+d=q0Gj^$B_UfbW^75wN#26d@1FXbIZJWLoc|k$vJOp_<_r^ajdV708TLoYU=x+x+`E^%H4s5Esw}Bg0 zr*f&kTr@f%DpiV{Rx%n8xuVub4ar+ivz+bdFdaWd`#BJ{o3M|{bsA;J^Cy-4&~>0e z+FCz70rCY-$#ZDWGmCpd3HTY}5#XHduPMJ6PJY9mc!-{om)n0rQ+6<|-7{qUjXx?^ z3;CM!DSNHy@o;2ta^z~>5GcWh4vD~bg$tHVm)*n-1cv$uISlCmjTw;8j?Ox7{M~(h zGDQ4bovh>>xGDkTweh!wF)PASZDFz*hU`aQsZb4c+tkv$Lw&lg_CiHoLja{P8ndlg zkNE7>#digPreLz0<1wtg?p^-T%FPtS?u0$)h+HGy(DIt~m~BI}$}3F@N3@C)x4@7r zLXt8+dlkxUNyb>=GSKXq*#W1sG8sebvxj5Ix?9tjip`pXX(6-J*dhn#Et6%hnX4}X ze(1=$C+>s0ZTD!S#EaMR5;oT-70}G;!+85CPO^1UQdbrbX(g&dZfYglZ%J=Cz7*2dj;h3&@DJ7?aUavUW3#f&6I>i1d73lKBz zupn<=R^zB|FK=sOBm2J*;;6P=pGN*C{bD2UUdt2YoYQ&@EB`}XB2@rD3Ho)_b+yT< zJ~(=7?zd9O45^)kdDW977-@M7l{JzY{7vi-nQJopiR0BAK{aaWracI)b= zqzo_hsB#A8RYj<{r!^oqFvmFGvP%lZbzvEuV2cdc7(b-_xfLOlw!=(|%I;~E65vJb zkB6MxuZC~;Qh$^OkE}(0w@l&(8tP$oF5aCOtVT_VF4Dh_mq$0HowyUL#a69GeJDet zdFyz7cN{d7kP$ave@+~vdA(W9K+uLcnPUsAaBWH+GO@SHLD@x^@Jey($gatBg=xdq z1N9na#&FD)wv^St56%DgR7l@&liMA_k~i|+uRah%zFx)MV~eP;6HlO8OKs1lfMG) zMNMBnqlhCLLCt*-`}+8T*JL}uA<@y`W`decSNGM)$14fUp5bB1@jMG*VuK_p=5{<@i%pD(A$9=~_X&G~Bnfw6wIKLg3lm7gP;&b30ai|4CUrY+&?L z0^TrPqZ>@Y)z2C-eGK^El9{lWtbho-@NRwtfGX%LkAj)|HI;9%-`pn!J}5B9hiCEt z0#7pV3w$rj)I$bZ;j`bd?iD-uW6`XEfq>P(YtK$tw{EUG%_C=qmsj^Lw`=Tttoy}G zDa8ap_U7dbFk(H$0j8RGC?=SFZOVw1!e>di4A;j*SXx>l>i|KkBh3ob57nU5_0)s2bv`XYl&{Vi4wuf4kFwIs!A*Nba1Oxk6)K3)sH6 z^Vn$m;o+*)ecJnH6_jL9D-Y#H%RNl%WPFzlS)(C$x&9j93J+^6BJQ_z>HRi_&icAVu!O*kZ8#$w#DO|WqW3qZ+&nvqAN@1 zcC@R1VC6GKk!q2EfU$8L!@DjhFmM^nO!yVy@lrb*5|+H{QGx7v(B0pu5A}DY;w_hU zNM9RxJDbdrui;lXR(^hd-lgqn&CSh#y+2O%3=Q1?+BA@RlOhg2`Una+U|TDfPJtxg zDkyKSLA6St6p0=ANlG|I<%SB@2!ayaaFgEIeoQAZK6$+{)z{tGM#x3-Ni`g{CWAC7 zzxruMaGPk6ibT(SzoaZ^LvS9f@6-Wrh15NZ0u4u}tKP4{xpLZtP7GICx5J@b|B4#65DjZo+h4tJ@P zzE>t7Q0IY+n5uFp>rV~2Gm}^C@q>}W#Jq9*+Gd%mB{lJiX*hjo(0G?#c6KD=KixNM zu$p{B;#u0EnrLy*<%C=l2HAOn_Ve{JE#}ASS0a27vOcfVTlY&yubA2Rh>nk;OS_>% zeyj06@9?0i9!vu!pqAy@b@eM1cqLt>gubp+^?jQvV7dVT6k-$c#9JVR3ob3gW8n4uB#n(uD$TSxIY;fBfKnL(F4?uH!b#zbszDd25_3;7U}Fl(n$krbeopcN#dho|WFL5#n+uZh zPJ3p(*p#d>3_1+isiLdl1e^5q^d~h&pXz{1hckx52*i~W(O~#yK2H_pQ+8WK?)OZ5 z#-Pp%4gWYhqu&@yOHKosK>xWvBk^CbVB}Jh$UXJT)S&mk{LQhV+H~Z)C}@}rI!APNbex>Ls(Q-l zSjmA?8c{ZY=v{O;&fNo{*3)P-cbTpKWB!}N#fiz`GBfL#`q}v~hTq?d33KV1xC2Tf zrHR!2RT`sSzP<}qaw0$}rU~4|@aEYRu;%ZpuUgDjv4ai#l4VCIj6;~Tjw$Rfu=5m~ z4`IEtBUdpQKy96xz&3EE)xEkZt8tjfxs{a_nTXLcz@xzYkJAP%tIDDH-teyX^afLo z%V_HwJ*w&6=}@nyuD{i|G$gj@V37!e^*oMhQ^5+Q;vFtll4%1!9cztK+D%re2R=fKbOCvjTSuMc< z2(AwxOF@&R`y%LTNMc>(&WLCo+H#uME^`xuZgl6RdgIg6qg`EePW-3)*`GlCZ@mFa z{)VrL-gGnOHUF`xzB)8i19jolW5~S^##C*XMJQcU`NH$U1LeG+K{1wC@2@qvf(XIaY!ma*7@oZ&$WOyE3r&^JXj5v@h5 z?|W|z-k(2%*MqEZZXMSa25L8tC#4c>OU(!K1&6*N zIP@JG6>KsCG*DDfr`~`r4{KvXL)M2e*6QBZnK9IzvxsGyWYrAiT`1_+6cf+7MU0}L%8GJr@E5HLW9ihxKL=@2@h zh7M^Y_uX^Ox%cSYXNU#^ExkSm-VlKy%n(k}uRIjSgDk`IwH}l2sbFY*w1?J@N6b1}4^R> zq(OzkRRVPb=lx%}yaX)}=wZ7V7}#dF!=w*DfPq0lOB^RYOOTdE*Rw(uWG@ZWmZQs7 z6)U~R=V~&@HeZu41yc?x(}8CSU4Mjn_2MFOJA_j!ftym`e2EAya%H3|CnTnD+w*Bb zUK5vH^VSO{X_oKi#6y`FbYWe|V%W=2X55eLTJt z|GqT1>eqM=7L~PbEwuBv=c_-Z7GJ+hETjcfbyhQ}acr^rP65~iii(ydsjF}|8tb4* zzwz5J^C77HkM{)(y<`;?f2+DG*^lCCMUiN(nrboo2Gka2>DnuGa&qiRoR)eEkv_5} za2ahIkX~OLo$*wCrWVtfd#{(>qk&!xWytC1`Dso??8dquc^Yqp{0bPjJ2}TFmZ3jM z|KO}I5?rn#jPkM9BfJr&pc!jz zqQ<e( z+6PY8M2&3emFdwU%+z6%Nc*k%FTLDXRif5er&ngslf*Zz8;VjnlsYHB1A6sD|4;0k91wdvvNX?hw-1&;g|e?D>}%h-tYN;+(=8$7;&w z_Teo(2#x;%t*@ApJ}mZsfNBNIJVC;cpqC-ZPgn?tdOAA%+OJzyr(GQ34G@LvkSz}< zJ{PYI3N26A1cbc$&(L96yoO5JSAkor?GSu6vCU_Hnt(AObUUwApIl61vUtkzb$4q_ zJFhB#l2-g8fZNFh&rBqw-SRtQeoLjKYc@Hdar3vnq_w^bxg~9ut+G>W=ux|hmK-SD zw?e=tb!#j!Apx2@W9f;@^<3!s z3>Lo}6qk0`6kit%_37|mtzOCc0bT%%7yR_AQOJ7y30WOB2kwOa)C2fH<&&&yLnCtu{_=*FV+bB@H554>dl4pz zz@Cq{>NU{R*XKj9mjWc(t9*z~%lE=+iikU6^D>9>NBzyyfHj?^;jgPdbSc)=&}au% z^9Oqnk`AbnhA>&zJ}p2Jhpc`74zvm8D{3%v=X{Aw{GZrYmv2=$J?*^w)gHcdL+V|q zqb=%&%MFc+U*0Ytp#g)ycdGdo*|w`aH5e{%GB7_A8f()Tj;*=5L`wWav7I^-7Ta6+Q`nY3|#mFPO*HzVF*oGKxyUwem*!j zxYY~HOY*&xs#u)-h3>&FFb+LXP}9?s(RD05bmet2d=15MtE8lvscC4&&BHql zcOlUI?P30o^x)oJ|4~&YftP=?X#A^EJ|3nKr8y)Xk3YEK>3{Aeez7iJs%8ZA^f?>{(bwN%LWFP0{9g_3Foo;M%V_4W`{|X zhnl8$v8Sn;yzUsHU++A&;d@3UVd`q94~9+)cH)~mr$X6bVK4LAfCQAKWgKd{Siff&#bwVfg4Jwim4h%p-o3dH_MR%j-k9t zRJnC_a_fVuLbZ4utZ8RM(w~2VZ99cT+Pe20$O>R&f81IVGLpvOPEJBQbgB(J9;tQT znbk`JJZn+H(jX7zU*Y;Xm>JqJ>l!XXdEE5f(KIfKFs4V;NI?#~wGo{xEh}QgIFVY0OMByV}}@aIXC;+luzyQCv!j5bstsAxPYAH(5i`SsjGa^Vh3iw-^Lbejbn|2HRJ;h5y&sTH zI#OygS(<;SPU+r=0bP#qaW_|8P3N~gRex{(71Rifd<)8N# zotOgy*=;SiL;Y&4-Q590P9UBGSq=5!gBS=*r;5b>rA}^EJb1FST;Oe`D) zx6%RErQWPAYIQBP8gW~O_HW2FI(zFr?fEYU5qqnrd-|eJ`SVQLIr!}+!IFNe=pyO+ zUKdB_)cD4DxG<0&47^6qSIh)eY{M^36=jnnHiP;3)<)!mCqp82PM*9E9uHu+e0_Y5 z;fg_MwL~24FHlZSPBA0L!O3wkE0Et-n;laen2X6z<~@6}Vztt@hO7Q;AcxDrHQyCiT=w+YvEprA?3Sp<^xFKb@ z;9~lAl^^B2n4mNv`RIaxy3I+N?$5?WA1c2;5mWbBrMjk4Cvbtw`NIuW%E8_XlXk3& zm;NaRR#eW7?oD7=iLg7N<-w?c>L`!d<8l{^#a7rRWs|AyJ_}2o757t_p)C7S(3H7~ zP>pv&{dT?sl_kIRh(u-@3b zo4TGIzvquDkGz(&&W1wczEQ^H!S7xY$4CLqm zeM?U>FZpskLWM`~X3$D=Dkq3LeOOa>`y?id?sv<>Cn0{f2adabcYK|^u=??vpm`rp zDC~GBiPEI`FJw<$S!Wgb^M$g+Icy5IDy!!j{;hXVP-W0i#fwXtMUw{2H+Wy>A*Pl( z`R5Ok8}54gta|2J1o`k;B3C76Cn|;+A3Q*?Ch&rF1Y4(!zH;xr!WM3P?I+bFp)B{#Nt;HZqlV)LI; zFO^0$g^OyTL~z|8IIbHxXLF>T(g0FQRp{O{(9`kOoQfZI1a>K0TH ztekG)5|N)5yi~@L@|7=d4?tE=(E>!6&Ob6aZmrtgSEzjVh|k(CFv$ceOqEfz0I{=%QBby>SkcP=lkt?AVIJ8ZbMATDtd%As%PF}rqub30BDW%&qw^h(4 z;XZ9GZDz_qE|7{`;OCdu!&I-V7P}JMCZ!yUI9hoG*Fu>eoOvB3zQP$BLwA{}E_UP! za!E)3{{N-p9f(GAQEn{iy)dcDoH-NA5^TzhV-Zsjb8Dpep7TIY@ld?xm|nbA610A| zs*y?));_)B#o=FMbzfo;d)Xf~_FF3lweO+{CsH2GUGxLUP?3tyJ6=9X98hIPiAv8Q z`o6jva;REwO1+RVQ@d=Z>hp?q(*ydv3|Ysd$^IQ zQq3lORDR=_7ch)c45~#ocq)-wlz`N-Wnh)EuDL*_DY7DGde@LE*tY`j;Yuv-HJ89c ziP0F?0i~Am&|-XSNnDuy0a>np+Z>$(!G8ylDf^bUt=eST!QR%^Z}(e`4cBtoT__1n z4GoWHp)h*7;c)i12Kz)#Rhhh5`W3c}0u$8TmSvbjU|85N=5@dUQ(~pS932nwk}kZ9 zA`j4or@-;&hjGM!hs5#;($-_n!jAX0esM$&+5vEOZ{4~Trc=Rh0j|+nJHx6~5G1p% zW}1@psrN@}l=9yHilSdW{J7p|vq|LVm-^EG`+nbeniLVS*m_RR0MGX9+9crvUZ5R2 zc0ke5h3*QHZ-!%+EYy3z8u#7>TsRB&a9NmqC*=Z9{4x6upqY^ml3D5jn5mIxqpq)C zfwVc6z+b?uR4@d3Tc0^0RO^%eLzzvDjVUB8w5pGZ*9C2gstPO|=k3t3 zpDJ>}yD)(kdmRUCx`~N=l@1h274DBM;4ayCZg>|HAzT!Y0$b#=C0Ytqyi^G5Lp81l zz?)P1n=_`!z`(+O?Kx!ihk$zfi$r0!gV40O!B{!Jb2>b^0?gv}9NoPH@kp>^!Fkz=*O8d3T;3QD_lR`v??=9=Rqa|6 zs4)N?T9ik&Bv$lvckji3IS>Y>)Y-4rE4%OfHU&@qWxvzOK_s~ra2tq^=1e?_+>p8T zu1A<uG8)Wlvvjrx4lR_XpZB|`jH z*cDKr=JTb1o;vWVxVTupYEO~z?mO7`CjxJ8LVKLAlIm#oqUqZv<$Wi~mQVp-q@Npw z?gIWGU#@7H{C-A0BD=wQ6Y{in!|yR)!FlrhSGVN#lsiLFhY9bUyhgibTJuvl!&Ts- zJ}%ig<()15h+Z~Z8o0>Z?J0SMeMTj)6=bR+w*9Iz;q5F?QZamvq@-k2WaQcSr5`wI zuq({Z!qF3}q~7=!!D2erjKodr4MkgL{qb^#X~V2=v_Lp*n z`K0+842GZzxYN*TH4l_n1p@$#D|~eYPR#!_PzWEEv-9AzxxrIp*p`W8UMu&H;!x-WQw$j)=25x9w{%;{HX=!Y|p2mR4zu(dl!S+TP|Gb zI;U)fPUA#Vh-GV~rsJmTqY*+^{9nKlh05IPg6uH#qOg{q#{TV}LmcE8&4ZD8tQ%iP z*HTMMk@1jqd9c^uX#!fgam1x9Rz3wlV8>qWEeP9>RGqB)c$o_pA7$Q;4H+%(r&@s+ zCk}kE+^s|H(dD%#pu2I7RrQuMRh=%BM|5`OVr>33<{9H{UQ=yVSK#%DP(v@=*eLrT zBig+&)JjU#Mmb)5K{OiiOzoma52QGwcqR-WfSL zK?GWNwLGRhY{E-HK>-=e0)~?nunRqng9zv^e|u#gDBOWX-ADqP7zbYo((H0M?vit7#oJaeD}Hb!KgRDGq@Z6)fGFwCAr6og?_6UHBRhI%O?Z%{4 zM=6UNk=@gQwnamxA4ZW8VL;o9B@!w(-NDoI#ApQ!)5L|5_!TwQA#eXovUIz1*57_T6XCKlg&T7t4)On5nUAUYsKpQCxy@`{4BWiinB6oq=I>S$NYUWu$l-_aScF|#XGBOdJBco@v{@>BOfmZv>xd_ z5Gt$dGXaH5ivh=Ld$vjtv*kxb6#p$+DZteKz||Wpc?xM!M^ilI6PjM3Xt0GO&Anw1mY?d5E0a zrLOMow23xDsS~UJMPEvm3Q5#qTT7^JW~4qifaT4a?DHzQQ$WNko*SQv&5sJ6EGeMt zXn4Q1N|7)*nN;Unk`r)ct)6JVV*SLbqn4)nV=dJ2jsD?I;pdhP${`I{;@(Jv)SFu{ zH&dbGhZ!*ZGb!6802 zO74&qsS{ef64xc=pxfFM8ZCGsVPwecU?nr8Hb&IH#7WODKX9x+vvS+>JqNyS&N zYqpdAo>wCdCaU{tdie{u*9j0-zaM|;=B4$W=d_vr!ep`VvvBgsMq<4iH7JkIO7VWH)aP>OD9*xUx6^ZDiJ_Iz(UDRK;_CHakM%48akt9* zop~&5CA@>3>VOUxhPw28O-gNFMJQD|%Bb5KT(0Fjlt_gGbt->TUE!*uXk{~8SYRC!Gfd+{5vL^emOM4gkRgbO?9Yp_HMQwa)?gzStWE^0{% zt*C^`ZYoaf2&ZafUTiNo4r(;12;i@o6FuNLs zX{*%JVLR2LD~VH-a6-kCnz>Cn8jPl~bvgy^aw11dazpbY5#iV^Hlr}hp+8Aft=o9( zR(HItL&ZDp8Huf16{a^CoCuiv)$Wj1$t=DO3;?)kM|FB=3_~t(=TzOTqp=V%#4iG&&?+WR!x?8UT>(aj| zrBAi6$HA%?Agk?pYoVI>$vq~MsHyo$R@>keyZ$Ed162G%6*xA`c!4HkJd-Q58251b=u z-yinK)zrn9Xn@r)TBZ8V$-KCsAof9s zw9o_Q*Ux3F9-5Z6wIKG!v22>J%H<<1=wI=^lAz8=pVP~;Ds};$P0Cb+3~!St@%Qh8 zX;za3cUY4jDe+dTv{o2DH4>pn#usAr(3iYksbaEK%J)PzM%2jEC3EEop&Jczwlu2S zBPv(g@qvzk%7DogHQe<%NyXmSud2!bU7U5HO;oaS#zz?K?99g!;fkS^)FP?DcqJHG z>LFR|)&-uMaK;;pd<}+XS@Jw)p$4E#%#wEkzvzbA>-q0rjq423oQnB@j*PZ)GxrK` zcH~yGA`*EN=KK2}TyZ{T6Z&aXY|! zB|?Yy%5C27*;@e|1Uotv9*_>vb-DyGDJn+s$;CZtHJXu@re@a_^}JGUm%|%CmTT?% zCeDEE=kdEhLjP*!(cAucnP6fr2s3DXS-iU(NN+Pg@Fy!Ts2zLj(*9O8l9=N!nFrmBW`rh%kiH?*cCXqv<&*+%_W(8M##I&T>myp+WD@9#13_ zF6m&uI^Fo?+MvB&-;~S%yN1Z~5FXdrPME^>3-m0P8Tg^Am%#y*#2r}nC?zf@uJ+e` zyo{3&!tyY027x{C1B|LEhF2y8&C%8X%_94%*rh#$3ANKIH%|Y?5*Uj7uh)j7^!Q<- zO?{Z7QBMx8ye4!eLeYenDvA(@kG zl09>VzMOZxW`IYDullA4-FH13ZB098v?8--&z^nz5Mv6&rY1h1cZ# zya68-;#E55vn=EgNl)YE(87jQkv06-Cyh2EZbLJikHHJ!*nN`U_)XpA3a&BGpU2PT|t#i;ii_ za>qr5!?<^N;Slj=C*~-glif~SFs_DP`PSG;dyxkUZy~)QE*To91wv`FoY|*=gsBO# ztRRZ+4^S`8%x2kw>G)Mk|2QHi?8?VgXP6|tygM1zvIA~B6=rL(`Iq;|$f*UI@L0t1 z^)#&&`sAlwOmFI^T{JD>)at<99P*f#4pkpRcb+5KcKTE|M@C?4fyNq=Si}n8Y$1ThAe&QY0 zlH~_%;HWtSBFXY-j9KU$Opl|k_PCBn?m$^secix4zo?TyA}2oj7kl%gPQ%^NaL^lW*ekqN6W5Ih7Qz zsG7aZx;Aw%Q^}v9)4zu(m8^Q=GGWrVr?*GhJG-DDD`>7Ce9T~UZ-Unz!ISiZsf-K* z{2}3&gKDisUOr{g21)}0zha+HJA9#}$HMH_Tj|-IDmP7yxR*M95EI;6Zt7qdcQm+V zyl74qR%@Z37V4@N&Shf_80fe6YgZ{6uDU9?fr;~4*wd5%#M60FXXY1xa9L_$cUzKIEWsQ`3<7t0L2u%=}* zprQR<^K;+E>^cJ)l2=ymI<5N$@=&Ct!)RR zo|K0q18olz)ql>X?zFO}`mz_e@kgoDQH2;z+5l<2O*9H$lK-AyGR zZ$1u3V;UJ{+~Z5x+a0uWR6c7g?NU+E%)#yKhskL}JS}E*xsZz$PGi{|NfU?wn3_T+ z-^9eET3k8gAk@9v{?TX z=eu_|_W};l^RjhP5+|RsdSfb=&@ENDdtdX|Gp)5sFW-Ca>cXxz5NBCMK>K6))!65x zi{vnbIT_-KqUIm2RWJ=&`68!`%K0pRdeC@7f^*#``5`_eMWv2i2Yb>3WSGNRg2=S8 z*Yd~1+^;*D^?j!Ko$~8L)O=A=Pq|;T8gYq1lL!(jSVfi!e4oA**K=Eykdqi74oXPr z2qWiLL>1E}Mz4wnGU;(6l(30OvN(LK6VRKm^B;VN7U0IDV5!-SPPkfo2K(l0yV*Y8 zBSy(pJ)v#wYfvxnq7)8P45e;e(M0&_3w zum@%(GkeK(1S|-oI{TxsUmF_kgcp?-7efJI^Ly`=yWhMz_z2h(kNz?Z29jg&yE-VR zZ^a$N?u&xUF;|rN*C}tYbd{_1ZY~3)=2F|KQkztghq-j#ZB@`Kn6@SULq_N&aN(K9|cSt5vY1d@U^1hCVP3!v6vgQ=A z9V^d9Z^1m13CfeN5#fc{Td>i)Wm9CbzDvnimwkxH#>9lFF8Db|FDTjsZipCIhe003 z*gAtB+u)bPm7@?jB^4QNc2Uc};K(TDA?Sn5lUsKFG@!A}nEZ&s=NT}m0r9Il<$ngybr(8U4+Kv$zZI5u-Q&1A>W zJA1}@yCv2Dg+jrbsJgS^%_iTpF}`7Q5J#E+9sC#b`s^l3eJ8CxF(n1w5D50Nx@}*K zvn;X?1e0`qU821Wjr$?QLnIO*Zf`1x2^PM)mc$_|qORs+7Z(ya`1+`PVsoQy>9O~k zcNaoxSw_9i+Gc2{q~W{y1g6PfbxebMt1c$c8JfYwS+Q?q1HVdyciN>Io(@)UM{ zbE!}bnvVoJrKe;`#SG7HSZ>#!@T$-7Orl20%| zVWn}#ld3WeAfwJcByP%@ndyTku_?8#S%&b9>B44uvyu0?{Cg4rZK}c<<)GjyUp=XI zs5(<_m+py{&Q2{DK&h{Vt8C_aE@BRVVJ@HB+4r{m@>F?{Df zr_>rhsV_FS&hxhGjZ7-LM=cpCvmZ)xt~^s?8KP&i3LBpA%=3ck&y~5kvoQKnlClIN zP5~5bRtf11jd}+leE#a8rRB8D;jA%a7b+iBL!o5<^5aX0p6vP<;s}L%RX>&$tmz> zlSwvb^GqJoQxc+Q;^|iFQuJ%j;d?A(VL!4_n}7BW=24?xcIH7Ye>IGE3*zHvEtKy$ zIw~Z1Rra}p8m^fXQER8{I^&e~!o;Z2=IbMqw9K<_P>Ocws)44WyasqxS-l+0V;$t3 z+OYAL^{-=I6<;rpyjh4aGR~Ta>6<0srG}l_(Y`Q5$Di53)8$NtPR6+Pqmw$f#e}5nFSc3xk>RA4@ zU(FBkQxGLv>AEB6@jpD6lUh-WWy~81RIVc>365L%j}N}9VxR;-6b#Tl|KJ`y02byOv`5IrnM3>f&(DAE!$0fbpMCI89r&jX x{8I=1AJu_rA)LcP;df%WHSEKm5NIH>VN0}9nJtAfh3w1=XG~A$8Q%K+e*u;w_)`D? literal 43292 zcmeFZXH=6}`!9?-qmGKq*iaD|brhARQX(b9v7uC@ON)r~Djh8#MebUc3&MlZ(ko{~Kz)>Gz)#RWF|J z(S7~;VMDrK&u_O)u5aJ1=ZijZiPSo|_4cmR*2k-U;$M2B{z%@63o83T{TC&~?{AYi zjJ=9~??HP+M!T>W(t67dBr#`8Qqb^5Y{0HWr+2R&35Do=~|`!!p>wEW05FTAW1Wzn~z@WT;r#ZjN%%ZEP= zE4Hl^Y6+T-#_36T{-Ni$B-!7%!YFK(Y}oO_a_|sw)3lu2&vMr;{cabNGslR1;m&sJ z=L^Zz92-Bl3&}b7CS#$7k*^f!*`(W_67;LaVf6h!^?pk8*lDrVV%NDtZP&bWlFvIm zzkmO$#bWcB(~bHysnC0UQA6dyk>=$MHn_rW_fmi6NJ~L2-7zGy@cE@z_5vO&JR)MD zo)gYNmI@ShNz>{5K8$ZIZHT_%As47c+q{_!lewi;+PX{ELzQ z#mKU*@GnOGKVszN&YIExY61U}?SDmve>3L))y5HHk6gCZIWRgmcyo|gc^t@PP6;gs z&<2 z|6h%8a5DebU+vz|iF7kPJ-x`tNWIB0oU4`T)Kq(0c5ZI{m)RGsleL3`VV0I;x2#N* z9nP*ha=Elfodqe+%?!>}FmIy06=D63f=v9rbL%U)P;;DdFO{?uvvkA%Sz21!Ni(gx zxqHqiDJjuT`h~W~%efUAYwPt^LC>6dw$`f@;;e(u&p#a59YGVdO!ALs*Sp@JF=H

9J;TU>dXz&G9b&m9g?f+ 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-idle.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-idle.png index 6c306467b709305d1d9197d4d18c6a5ffa7c89be..7183b25afb8f200fb1fb7d99d10b0ce171b58d5e 100644 GIT binary patch literal 42126 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 literal 45469 zcmeFZXIzt6`z?&)GggW-7E}brQ9-Fnmlg#Tr3gb2q(v&Tv8;74zM{WuTL4k;&XZ6hQPPhe{N3k|)Umk&gVYG3M5c=s-{ zI^L<{^j%G({Rb5tlg?f#Z7w_0{^X9WUL$QfRNw>P~in3=nq(6n)*r8IKdu;R= zR-^b}YjdUVt4%L(q509M@zwb0z%eWA;CtO0tUqolMVD!QPZ(#?X=8c*b8S!929=8^ z%;R02JgB$+ky7;e_WSya4sYHa3OV$nFrRp8*yuyu^>Z&eWv)K{^J3n`^^6I>1bgK> zW}B#E*|tMdN(-Mk;l{-!6Y5;k^9!g_<|QeGi4ypQe1{-MO<}s%dRkdy|~T!|cVX7C!B& zhmXRaIXUx`f3Vq=419xVXJ=iaeK|uKf4shyNIH|NV#MFK4+w4@mDg#QphC0uT56d9LX3 z{L20L?#x5(>GQPyerX5%c<8wIE%%?7ivIoef4A=6u;J#yzbW%?x!}gZzoqiuHUVMq zZ;SjF9&pRSzwqE+c)*Q=f8oKu@PHc!|H6a+>*0aHh21It!v*|1P5;Xz{L7;JUjUPq z9?&PmWA?W8mM9kbxyV4?TLgXC%Y(v4FoT%-7f-4hvJ;JgWBdH)?H&8RCU$m7_u@hl z$(ny%3?=w7^|}8e$1wbttoxU!{FkKtKZMhpIHPRS`blr4l&1S~bsY0a&*vXr-;krK zq|{Bx;ZNfi_ExDyVUu$H15-QaI1u5)g2>3oT(iQ$!jn2vtBm#ab-%eTKR>_7qs$HU z8+c?P`~m`k z`y!V<=jZ3c1Ic6rvG{l3)|WO;Km6runyEy*UZPCJdY_A(k=oJVnd@dIChhq)<*KTx zLrdz~F^OTc*?ZypaUMc=&&1?G@QnoxX3_5*WYmUloSRumzU ze2;lPrAbLi2^M?c()4uVzNvwMfpqQY_4(fR{{E2Y)zl<4HkzLuqc2uo;I-vasN-Ba zHq>OQgj$OlsM~exD48+ThdCT2l{Rb3BWw zs;X)jo~(@AZ`apX>{9AJcpPH@r+MJgjuHN5j7CZq<-ieTx4t5;@tUr#t{0bMs>fz# zW?*SL*RE9->O4OYwDfIve_x+_(5r&6C7cbL>TNtURFr!Q(Ot5@ni|?za2gtZq8Z6PJsj~a*DCXy9hZsBZ&+Z?c`-;sJ^UtOleRsoEl7`TR#?Z>k zZ>xfqVu@PL|6q{Ihmoy=gEne^X=_(~{d(!%y>WSpdYo;n=v0JyUwgX=5{W!}NNa-_ zxP12vQgb+r=@G?lZ~WnyE{Cl6HuJUV=c|_K?0L)M>EhJOTjDWHB8GKEoF{~AEM{kC zx3;$S_VyMO6hQb1P4P2}+A;735F#@{4qI#3>KlQMjiF(vhd^ zZLv^ZQ|!_`q;BWw>1l5-##<>Q ztRA=&MPWH;i*B!e8~&t0WbAs+aavOrKTb}$^|{sV&NveO;5JC zsOWU3Oi+WNil(N>uDraw^cON{=Mt5zEkX1};bByGtIe|zZ+Sl)CWt=e)7;dQrs6dQ z$+xw>P(GOgkYZo~s=(sjbUo5i<^IA#J>}X)`$CuQ{Dp-DzB-(Rlx?~HLR*I3$zKy1 zCIykGW%d@0Hb4I*WvmMz*VI?Ic zi~Xe@^x4khEG^!_FAo}8-|J}a;uAmyO~&hp1by&-6!bA7{qWRk>2}P;3m1NCb1)t} zNF5jD1g=D`B{6$uXlQ5F^3-BY3Bl}rWJ6>%)npY1CmzsXvd~vNCy!<5AB_+TSuIR& z)MQM=*?m?o+_k#8idJS`DIuI*n-Y3zOANe_hfQ~KA0}y_+WXA+PaivWY%k@epIFrfj!XY_PxkxC$w>0O}c>Y0t3~?IZU68&1eUdH@@#4EQ^37=b)$A)u zL!xZ7^_xYCok~jN3d)=$8~W{Fm1xCauU2Z) zM56olMFj<4pPhWaDcQWaC@){g63N6ftw}D_Q79FBUP@~NzC;HKW^1vW7Nr&)wbwk~ zIw?8XGOV)2hk`|LI!p{BasJw_Q2)y2EPf(Us{Eul;iMKMqqw_SRHG|^&FU%1(=!xCgsFe1O9==RxQW%=j#N)ZFK0YIjbF|+d-bS< zZ>I@;8fTl*sBmBM24kY9J+>4zp`kGxy4tIW*l5i46D$?n(#vRjc}hnlaOBa{lo#e|xDn`zetG`*;$=!G^}z9TYSna>hyG}YJ2$)SsawzNQhrE=e$ zEl`vlu-3&r-T%V{gs!hlHWb^$f9cgV@1|4GD-(5VPuOn>GQQNjcA=+T&9}2pOG~Fw z1IKHgDWg80(z%&MmyfH9kYSl2>1H0Gb5^2Zo1;ss3m$05@c#DB%%sLn*L5v#9jnq! zv^b!f_=bH(Jfu%rQd@+8(Ayj)pbtuD{^!r1eO(%q2WE)H#eTlN*$MeJ{cGP38mcpA z-pdk~Dmyw%(~VS6n@hPkQeSK9OUiUrRb5?O--Gd!f4KK`1Z)+OoW?t1!+bZD<=oy2 zed*sN*gPr6s5*uA+w4OIDCT)I;F2Yqo;G8V7S{WceV!#w2Ut_p3*GGOP7<%~!MVA) zjhaNBIQGQhdcmkwM1X)Obrs8D4ON6_7r41()7&l96gHR!%184Ql)DWK1!rex`}+G6 z(Qn(c43Pw1sq8LF=+-)*1}8=#loqI2`-az+MiUz*y=4NH)1$i-l@w5Q0rmG2((?Xi zlmgblb3b`mh}_!N_WQ2g0wTPFtM&1cH|SGu3gzXKT$OD09oKa{n?nt|T&iY>Hzt&M z+B@2LhE^qHxAok8VCPbmPjKBXu1-gl0+^vQohGf*B3^CbO2Ef z+TES20Hgk~{(*)@yMO=wlSnA|8KX4+%&e^T%Wj2ljV=Z(^mPsoW8IG&k{T8~n<`J7 zpm!sO3-0NhO;2h}JoK%58o9j$sLED_%o>TmPqi~Am0svaIWIc8oH{?xJI#C0z3hTxwo!6mnAH6ip)lzTA+%xL*bl9sg4Dc#Wi?35HaMj3 zxU4MkiOLuJyLaz~+zTpq8Xq6OckiCv0m3Mso>)q9vY42dbC5!|d-9>MOg$-WJ9Yng zi@=~CZ>f3m;SQOa^wD%=ZWBeSE?zR@L!Tk;aCD8{ss5A=qEg62QZEOEu2pu$kY%5} zx_tCQlApi7(xJu{$*O5XN?a!8L*KP>0-;Ud#4Bp}!tkc``uGj{uJ_@Qt2AoRFd>RQ z8WX*DG)91rue07`VM1D38d|V2MGEWIn;E;k zzuaEJ)b2HWmKZfB$EUWsyFO`bbF5e}3abx6TFM?>zwh@31%ttwd=^q%Lp$*OBvM`_ zbat(cCS6yw;N&vg8Qg%JpD3VmSH8$ydHmzH;#2YzVQi?=CzVvnM7Of*Q2)wOS5$^e zkE{*hQ03TuS>k5^uARB&vgl42J-GebhxhSkD3Q6jE?!<2u#~L5iV;74_IeTZ!1L-> zA$D)b_D#vF(-bmmrQu|H)Y0S3*29>smQI1pgZ%kn-2$JsYYlqmQe^2}^aa=8_U?RU zCK^2|D05RJ?{s|Yn2}DtBx;^=pFNY(6>4R*-KN^KbPVcBH?-(_Nt`uw^z8_bkpBM4 z<)v@ebaasDZF-A#;&ZI!$Nml_(;zSEe5dfp+!uO(vOiKHRVeA=7Rr9UwRuCqu&BAQ zF(N7i=82_8?N>9?A4Lsc+r$$e`TOM&l6l1d3;qUZ`JeCBK~s-i&`SXau@+%V^Q_07 z9~8UUB(`XZnqqfH$06e>7zGOb7y5>VhL%46L}PA_j%O1aI=j20n^n}*xX2=^TS-CT zlU1qAT9c7b+-%b&?Mv3S2$x;Ec4;N5drw?HidFS<4(=b(!XKzfQKNCN-P>{2Q#It0 zP`^-w<`NFQz80(BY$J?3fk$_$*&f~U%_eRZyo@EG2RWuGM>Hwy?X4=Eebqi;VVbCw z=H}*>OWc-9(SS5NTWHspus@`>#F{`1EHS=q*^RfbRZ$G^)v4M}5#zs~<;|B=lR1^i zBrvtom)6=1J^G5yo$gCWNPx*?$v|9Oyy~J0tt=I{hHxKV+!^utXlCT)>rZeuMfZp6D{T#tXk>g?CShd|NoIzS zmPU0}@EO8SFrr)cvyH7arHM3d2lblyTpVj|ZeAKd8zPZN=bPhKXt|taY&k7}U2een zL=Fv59qp{#kLylz#dtI|-gj`YudS`sUT(CP1E|4hbkK|&`l`SZX;a$?r+!5rKx@9T zIfl`x#~HKqI(xe><>RPi2zj}-1hMWhw6>+J@GYDZIO0)**`sm6w7SowzrVlzSyWpz z6c?ASPdBU_RHaQTjRdNexnt3&e8h(yC?ngK?VJ!B%{6{%$&2MxdZaJgt*9`ksd&8n zTymnsbmQ3O#MlI1d!_gd+TbzGYr(W;Q>7HX;WHK2IkSXJZU~W$O)x> zi^EfzHk!NqeJlz0s2!#-B@LfR6;JaSt&B9ux_Jf`vh2NAohP6|Jb(MLC89q$7#&ze z8R#aKuXiw`9tHT4R%4?%OLgA_B7f;w#psvdHWS#T`UVCD`uY+4D(dRiQZmv6S7&De zjkML3XJy{--tOi<;nNhX!1#H*(b)mUy@=0m`1!XN^!PmVROdqEZ%py8s(B0ZF}m&+ zoG(AGp{bf8l_zSzp3NpRpO>PiSDZ#y6_k{sbggnMNgCTZX1RTRePZI;J*k$~)}L@V z%IV&XeD)BvZNdSkE+R&3qnC0}!??!AMqYmYDB0g_MMzgw>~wT=U}6n!J}7=OLf*Ca z%bB7Ps9}Go-h#O;Amyx$9u7sBJnsGxn_aY*gr|vq5I|@$cPHtK9s2s{(WA9nZ*COZ zXs)$rkN#MxPEVJHI^{XtoJOs>SNl9Od(B@Fv_MEmX@dX^v_p1$ zY|nHf>v~+8LB-^H@A)71ztB(x>iMck1$SC=Z66>vmu2AZx z)~L0rMMW_E(azm%PU(SNUAX{80GB8#D@sdA>EF~p4KL}%r3}iUEu0fp?^}MZrf)U$ zpJ+juXl(V}3pQ+^E$~p_`zy{LsxnAX${5eW20JrsR1){!Sf1_7rLsz#GgBp>auzm` zXG%9FdTJ#)3*8+1E+`?6?s7?1Ql7SQf8)PAR{cV=>P~-&+Z+TM+w^+EXlR`CE@2T7 zTXsis6d!e@uTt>6+gUvISS5?iW-nDZAM^Eg&Ph+VWBk(7rcX`aaO+a}lMsjj!CX^` zd(|0BiA_`89k`0siI)Gwn(a2p$jC@84hhb;EOutB)TKmf9k0K$C$rkv2`c8`D@VL> zwl8aQ`NgR!m56FZVPQ&>R0ah>W~!+r2e`Qpc&}bWC^Jea0?6UQuylnuev%!1%e~AA zXfH>%xObM(dB;wi;Khpb9v2ewa4g#&KR28&V?|~KzB%&5*?7BiVVI2g&eL{KU+b0Dyj;zdrwR=wZ2qSX`Y$0=t%|o570O82F_44V9U1eeQ7m zs^2p@9tHH!VzVqSU4Ah5><0>T)vWYFG2bffX#U6}NzDUgUgOP~+x;nt`F~?2pCbGh zu^NhufcTa}^B?vO3{)M&I7AiGSd^-Qbs`+Gy z{gb#1-2zRgu+^nfO?jp(bPN^kbfcrf!oosAdRdVhIg0gwC2`hx@3om$JU%1j={lWG zUt5#nd=jE0z_X-%u*dU5D5hBmH=WUXe5?6$_b*h&fRi)q*ErkLxZ zH8)exUQ|Og`w_Y{lcu+L=J?;^y@lQVlL^vRGDwoTx_W9&t?6~_TsLm`vt1QmVq3rj z0sVy&PBGYSLW^wuuvpeBiS=mk%XhLsJF)3eu2lgzcI$4-VSl&4`Nn}H->VJ=dU|>x zAt8=w)SEiRzosKmbI)1Z0t2Esjtf5?_ufknvrP+=ek!!mG~By2EQ=Lwa|_Zu9 z*}(pl&Oo%$6r?uUX8n!2d&)G`9o?b~pX=!EQildw6*KlQ-IU(j@9*A0XYEA|(2Md430b`vh!Ift{TvJ*ki{8l%GL zx3b*GbL#nQbS^bb!z+k+pRg{!Ir<3eft#~n=XBeKNZagCMIex5sT8r!rnzou%{kRD0 z&0fh^G`lY=LHXNVJ7-s~v!Qk0I*}XNb7Ajfd_uGB?}?RW1r1}KZHag=ejH2;Q@hd? zlWVJj*kWE6XPg&ghTC?OCW_LH};6Pu|mNNroY_o+YhRJMTFfC`j(8=cJ1EH6=sqL^SAstfgFm+{P1exJ#-my?^L1R!RopGeW5VmeBkhcDKBFSoVwz`r&bbQFSJKv_El-Kekb55Bvl8jBtZRC};#z;JymS^M>NcSbs ztoZSmj0_o6_H2~-!eDtoLxQyOiPSf5KDx~nvDPzTJAVTn#iF$txMmrf`Po?l6khG; z`mqjjNmEl(E!qIX_=Qy!G&F>dKk)xBx_gUUw0CNYJvhQiHVx3|YbSE}J&N+}Le1Nm zqg45r8|iJP`Lf*s{{H!}ADua74I387zY@asCUre~)=v)nC`m6F-0%cnOFWbWc|$Au;G$XFA%7!ju#hBfq1n46Z{JUiAO@ zBAc6m!B^Lr^f@(ERd?mQQi&qs0e$T$7dJP)@YWoBZ*OTL$sym%pA_nxdA`3ZFiWy` zTDLpE5=T)S%AgmDtu-a&d>{lWv9z>y8RTp*d7<8I{!JA&HAxTCT={WIa!D5rLBqjv z<2(QHGqz&#gGav#{tRIaG&gPQt> zE_Y?-mUDkwi8V|nCnr}@Kwo&F35ChI)(3CB5;@Ui3w# z!~U2;yU$sSZ>)t4T|!9YwSmq~#fuj&LQ7FpJp79n^)7xCHJ*tZC>p(l2IBd^Ip}2Z zx%rF1|L6xiB8R`w)!xa6*dM87ynq4f2LP*!lhX~4zk>=So5Rqx-9qA=a`n&r|~edWE!Nn2b^TxHzU*;Dy3+6j(nAN zaXO0uAsS+scYHcleWSMeR8@7u`_<`o$>DG}gL2O0joq&%7Khj~G|4z8pfOdxEJYnG z^uxQ}Q-b&^Q-fOl$r?b?$p;{yQ<|GaG=hCWfMu|rq4E17D;sbitGv>pgP=X8LT7%`)zZSPp=$&>2z^HCkR{Gm zLK({2`lQd45aWu$=`_QmwkiBW)*MCJZO))HzU%A;3T|8e!9BWn@ePw8g74hTe*zIk zG&&oxc+s{PbsPMAvPpBfuOtLEsu) zoSvF$+Y3YLiDO5P&QPg%b(O%$`gqU#(8mW=&!tq*s|Sk%Ev@!JhrINi`;=7R2FGh- zvjS#6C1vN}aA;~q;_>6hHJ2UZb8;npDNB`cW_at8yu1n!WEyXmy4x(A z2L3lu7J0K4h*0kR!h(ZPNpEYtSNEMUN;kSL$}J(rNp<(3jsh#KJ*MtCa!Ex~O)VvV zjW;th^ZAL3##YCYlT%Vej~|za*9FESXsuNzZnkqdJvXzx@*6?5lnrI@A1>_K_xth5 z|7z60C#9(cbm=|_boTXKEAF-x5)px*5!GZZRS75qEl_`K)?tCy&n!J8Q@n1{+m5|_ zuU3hdH&s1oWvn{p2EC7wXI09jM30Az{{_-Uphwl_dBLsF2rbwQ} z9o*7#)iD`{8-tuYd4+nbq4)CNo0d8tzGgmvJEWM2cWTdsrW6f2DBfD8qYaQ8s2rji z!N725ceJ)%P*f}iGU~t$h%uf;zTW@v+=^l_7;t3gEo}VCo%s0ppmtFIAfD#cn&CAo zx`l9&OKOA`tK>11stHsG8aA{O>fFPSC$NdS7v$yR_=i6K6*ZRBWbYz$#%+*LTq4YB zsw{L8fv*OJwhA$u-9fx%WZEVysSUx9fY%Tm*_uPj%VA) z9HcmChI!NFhs!oY)k+qw!#kF3z?k_-g@FPb3$Os(6wjVMJ+v?K10k^$Ts%OS4|jH& z^*2_$KE%mkRD~Ccw?{7+xPU4@mp_*#VUUW^l#>gQ$u<>>8x=p!8DDJ7U%$_-T3cJ`$~|$PWBc6pCVt^08JusO;yK+q`l&rdroa*%O&aSLYdC0c~-OivcRohekwB zK_!+mlR0#gv)GAbXHAnFW~^*CpOXV8W=l^n6Q_qbn?F3B$?#{$W73$ppx@8p&^}xJ z?m-Dn&#XwrH1zda&V%d(F1^jOc!pa5nzistI#p!$w;06o*xK}w2IRmFrxNw;q{rf6 z>ns6OFpDB_gOQPxRrw8wYQJ34B)>z5)pYlVm`jEfVLk_LZ!b$M+YOJ>b9~L|jt4F# zKq1ru**!3*((>9RN#eOpO1XuEG`$5Ov^}Wxz`dxf$N;5H$|KuV3E%8_?KbOffi5_2 zoBw)RLY{u(-p`W&KgFG^*(}6+a#|O~I-e~ZO52WF^q6b38LkNZ?76)B5`nUth`W)o z!s>9)jc;o!a@E|pCaBQyV6Odgw(Nbu*;vYUe*r^ey zGaMr79?~E^P0X4u56BO`Pf%!0n%gH3Q(fs3XE->Tifc|giWTJzH)CKjUG@II;&S%Z zk=RB}tSN1a!8bVUADuK@#H^V7(1szCRMx;SLArIqpcV$G`}aQ>m63Y?$~I=rbaKj# zZ3!#e(!#?pmZSe0MVxn66bR{loFFy#MC>xhL_}zAdjq z!uNO1$@}^BMm>&vxnK~I^<>=2XSed{oXDd8;R47dw59pcIZBtTTaa%-PDQ?hA*bK) zG==XiP#9w<7`d7H+MFi^v0t! zkrMIX?rN*5Cb&(ni}tW)7BhGM3{H)o-N_^qf3aWH3PVx~@cQiOWmCoMZ&H^F3AFP% z_fWHEY0+{UN@ou-$Ok$k0HKdqD6PP93mv!Y*;GOXL>~AHd z;(9{Iq}4T7qDxasEN-(lTiOb9y>rdUqzf8lVcSd{(NJG-HGs+ix+e$)pnHbSq(}QN z4kX=F31e+mR%-pPdhu&eNNs6mxZpKS#^w(lcg%s=X%Aj@e}jp5RiF#wQ8hlJKg!?F zZ=t_bwt_zG-Rrte+FBGkdUVe?EFZW?sP7L$o^laHn@#tY+u(4iLXIh>Y}1Oo$t0`G zZLOe!NY>{J;xjQA<~&(-l}uD&-TzW#tq?HW@omivhPb@k-15PTFhwL#WCvFsczS*| z&OQyNFrYWE6FSypt{T~osIa0uE-Ek%L<;r9ooCs|zDHutMZ4!`?=D0dK zauHe8UQpD)@ChO#p#K63qP7IsZ~o8(-Qn|>`>z^H1JE4cm|mhvEF~o4D=RzJSl9Ey zN+wkyp#=GrDlQIASCB|-z7%_~m8pV0__KNbE(x{YOKWH7hi#XQ67{v-0#0HDBDQc| zidT*1L(2Lb34lkFv@U3J z_~{`D_E~B-N-+y4;v4&49K11j8VIpbR?tR+#5dtUR-Bq=dr{H99T=zA6?@fvp#?v zcd3)e{4NThR=}R_J)hHwMD^EA2Q+{k0H0T-$>`jWM#z{Gqy|jGgTS8yzXdY)c^C!x zGmW!>u>XFrzP`RPy97+IuxigZ?z=vMeE{rTI}VBmI2}Fd@96qs?Syw38IwO=p0bI@X?gBUN=|-4>+S_pSDkDu^F#r~-f=k1`(=Vxb=` zEk^1+c%SLxLPuay8V3g<*d)`PNb?V5)!<#A5)^s&hRmdZ^QQj3+;Srj2M6@4A_Q#% z(_w63Kvt(G{oJ?{^S|!+`s|&lgu%_5QJ1e{!A@&&&)&?;%*V&4=u7!usGtxOih|KN z`4HaC;^RBeZL9{*P=TrkNeBwS$kZR)l>vrt)#b6d$8h*WAgGGKS=Q_*0?7urpY9Yg zc(vgL&RbYmyh%xkfmM`UEQ7v8gFTOlRZk{2$7QU1nItDk9@7v8hBSOz(1XUshhV-YrlaQ#;KR-a zd*s^l`D9!jz%LM_0TDu(Luxp{=Wi~LFRSQV47NH32LlJc^`ve)JoHrS5~DFGJ!5~& zY78~MK0hN*k*@gjWOs8<_WJGHy`9-ba%U)a0! zxwyC(j}HPHMwq{&qa$!liNm|r3^AN4L&NQ`Owv-5^4VDacpbwdgkdF4Y=HTKVTLjH zLmrF4Y2BmF%z&MDac&Mszbr#6$#YceV=C+}-rC*SS%S2p@$u6qUN;3^EfF_o=j7B> zUR>~UwcrLQo%~0183G%eew-j8C7WDh^>l)X4j2&>aSxtLv1Es-PO`b}^v2ImxshQE z2%0~1wJ}M)wLa=P9E5q}UX3Cmkp*U}ec>9C7}QFgC#xgdWONb&mAN!R-e%4?jMp_O zrYt%KS3l2z?-%kPt=5i1CjVHET%Hz3{6-?-C)2tfLFApba9(ip-!Y;#^HOJZbnbY{ zA2u>6O}1s8c7cIqMrFQyA!p-(?}!w%Re_oI=%?lZXpvX1UOk~7UcKOhm1JkdGpOcn zsM`L%q1!JdkZDBI`t7=E#OB<$*hJk1Wr6LweB_`>=pSR~uSxQeI_#XKA?(M0V0rH9 zT>UvbCp){h)B_2mA7mr(dLe;8fT93~Tmb<{-QBx(0SRBZ3{DYgtCINvyu3ooO8p8& zkRzq3{Sk{MZ;RS`^&QY24@Vx|lpUQDzs4CS_pJ5HL;%}xU4m1w`|C3od;6)0x;PU_ zAOQi6FM>b}Zos0_QX1rZhHNNxp>!BicfYccOEKIDh7$j}Z(m_+V3GIa7OX0$LOwdH zGUEJEYr^?t(Wmg{*UxfetQ*|>>+Y~-UT|`ur`_K_NrOUsJ>{3J^LydV=1CwfV{z0VN{qNfO9nhYuf4kAv9n&6u~T?lv^%^D}ejmXe=Ky3+fXpPssk z0wbfexWb37^cD`jRYx=pgW=!d>$j5NXn1I$4*4l{NPgQ*UJ`z5?xo(yjs}R zx4Cs^7d%2x0P3|rZ78&&|6!E##q*v{g72XY_)2Vd@o^ex{4A}&&F*kf0VHQAij1CL zYK7za?qg|X1y;7ox~l!!mVCHprIxBdoT{b`eR-hAop=A{3W;xXa&nTBleN-#lU9JZ z8-Q_FF)Iy$4!eKwIa zhK2^XO|@7{SGEet16#3S%dB$FRf^iwzIIucz)+PQ%dXaz?&F4g=splYxpes>IWtha z;Kss1gnB5`_vbpemgQ%;BvKgk0$I}LUB@(-wvQe^4l}Sb+=lth$KT)o&Ye51u2Ptb zt*XJQt5-~|MW&zV88))at`cVB4Ls`XeUdUJk^Z1(aP^BJp~wJG;8TH!NlgVl{$ozg z6K){=Dqvf$3iPh-^dZS@R(C8e_dQu}{!+0Ti7$xUuZN#E_8N_eLoYK~ zh$qt#-oe#t5luJhzNj&71mnguaeA1>3poM^|KULLlc6MPTQ9=-*yyCEB z8`dy9%#gQUBa+FzxTmckD?yT78*VeldIPWNf9l37nV;TvmZF94QZyOxjjixzMPzt@Yq?R3wKR<61MMc9hxqVnfJ?OE@NE|n6O{~5O8w_;5Y@O zC*UO$8l=e*A1Hv>6o*`@qWG4_T9T5I?iL*79#=khszpp@KM4q6>#K*YkDVg>u46SX ztoG>JWm-r`9TK$tp~;z>=4=>lgsMoyS4QlNG?!kJHlQ@}0s*WMyaqKCgT>Mo7n`RO z1EO*H`7*Ld7x4^zF&LE&?n)1yB{2-rsrV+7@$m(j3vlwK|%I@;>d{j+N)= zTn#d~nki;(ZQa;ZLzQ<-87h%oHu@)?iE+oPX5U{ih|rS#(6 zat6ZQuCvqPOB1pr|Abd?zYgR4Tn~3e!_3k5K8A=3tLD>!H)Rv18jL49t84NY;UQmk zZ;&5wfG52r&UHDKlt5H!fRLE(6cr6#BIdE5R^$AQBf!wwS>6uqeYuWfIng}8JcW-u zh)iNy+hmR|xX~&q*4byjh=C<#B#N5^r1&g}#g8H9S?3b=MH-L76*-fG?U5)u8oL56 zoSDHu5HR3X+f7<8e)8mrky6!5;JBmDTOyHY`o#HB03o5P6*{Hcov1K-dZ4P!Es4Wb z*z*`^ef0gAZe;ITLTM3EEzR4fc=lb=`e2eYU>wF8yK)rx{``WE@)ON4L4kY( z4U{f3+2g-n%CYCJBl>^{%~_w-Cm;Kt($ zgSED{1~(_*+Fb$oxuKzgYXW2dkbmwLs02?ZJ&E^AqjxUQ23av%&hAzkb(!_jIOpI^ zLz%Gomyco-bTcRhtkG|TL|=1PE?_CMgA_QevnO%+Eu79m(#cR5m|HZU9w}V7a6wtw z{OqA!x4^I3`Fon7@bg<;VYYuEWDWjl^Y93ncC}*`q#H&=V~=lzZ}_s`@r^-Fxu0-1 z+jYr1BJ-GlR+Dvz{Tm}ln>%)PqnMDz-eMQ?+r9DVb8qVIi}mjsUmna8K-wV8EAkf7 z%nm=Z^OV|ZL^;Vo-580o`xW-#FB=-7YxFTp|z z6rAYSx6RE1nw;#kz%Qci?&S1%_sQ@soJHD^ebg)LyG387LmaAWiiZh>-m3-i-}FcO zi_GCUq(tjq1Yn8G-oZg(Zpp5V6(tWvE`cNWn6U7(TeqI(Uu!eEx?l2ER)%r*myVU; zLkYUo)?Q8vrM40_gl$I_mk)D4z&^$I@JcViZ*FAdjwV+0=+U$Bx}^i&4U;jd+DAjZ zuChE~OimApN*X-_^Q+y7-M|k3FXN?Sab22QC>lW8v=)o^5C(YWyZ( zS+DjpJC8ybJ^nps5)6`&}0?*IP z+7y(PhyA*3mh%^3>bi4h1cpT&xV(gBvz)gF07Es{u73f%Urm>vXoblF@FaJJm1KZ- z5pE$hdb-(Uo>ZU5YBI$I5&li3Dd+Ujz9XeE)u^N7FVyIIz zl(cQ)Bg)?{a%^)30@%%s_6d38UBN-FZHk|Q;UBMbrS2{vU*rbiwO7d)%yXWRkg=CK ziB=OX$kmFixl*lkkZ?bjl^u1qhdK#-qFCJC8*919ZD->8YDvF)KS&mxvh!4G5SuYw zm0Mrm5VeuES-<8mHV#3qHp}mS%iUH&8qFYccV0KK-PsHmutc_kzy#`uPD;_Sp$i?AXd~2 zVSdUyNL$A=OjdRm3pu_yC@_{1&2Ea^#r%?3F(1xZ@8+E4^wSx$@$#lagUqGyXSmr< zJ#Qps(ozx2)ysx-Ls34%P_Gr)9AibC2cz}p8!P;LrFsbAaA;b^B?? zyS4uQAx7JC@<9Zvqeryo5Aey3sNLi)y1lpe7;c8k8h%XePHhLn2i(F9B!beMD}G3? z8g>!t|?{d3VxLAPnf!!%R zxsXV1Sf2M;9^p2JWxH8Ic{HiVQHzdLJQPD z;9t_=`wSvltz`s*XJ&_V0Gy-Q(`ehXhu+19KD*|<%?a964iTh$!eklM3&7NX)>K~B z5m2t6^GPFaB}l~HQhJX4fqZ&v9Yy=pQ??#W{WO4beQ@674VS>!d=6iCTy_ka1W57# zkg%Ges^9BTnh^S4)u}Mv5?A7JTyHdv!^ELSc8Xd!rzHi_m+zWB#Y#)&ev-71@+XY_u%r| z4B{znNmbM3M$8fKX_(&Oy98{dfNh7&yAc@}G>M^Qz(BmPgH<&O+0cb(KP3_Mml{ec zA#%Jq?Zwg$-2NNt{*QJ)oRl2XWolN5};*nbplf0H|ME)+StHg*6ROn z-A}EBM#>5q+q><@?2v@Y|3WU(j7ae*`7`F=b>|YvvPbiDhr`R znlwas&hNaeva%RJ-U4utd(c?&Nwr=%Wn*Il3Ew@DU%m(g*!1>cqUk_g%5fvQ`hATlqK>(1LpsA$udGFr+ z3GsQy0B{pZ$Vze}6@xIAh=1NF+-F-n9^9_`7qxMI|8k&qZ|Q(UQTg4i5LL7nNg=?? z$usl(?F0#zb2P{Qr@i+IYck#Xhk3U%GAf8O78DSMaYmG?B7(FyFp7eRQl&;viu7JX zGBXH_prC_*)QE~m3(|X{U_c2)YNSXE7+Qc3Y62Ps+gYYehEy>p_xy+BScNAHwP_Kvm6 z>XON%5mB_ca4v0meRZh*G&XQ(A5Lljp2N5eOF4ll7uHUPX&tH2rg&lDo5SNA1%k2NVec%+UspK^#edD8G6UK zI?#)PTytb-D73lf1C=USCgS7ghYW&WUo6gRr(3!Gf3SeUV~34BIF)VLAI_oIE7JL! zP==&woHo3GiaN`ln4Cfb^1iZ= z*6^qwn8;!?9bH`vSZ~6cq|POhUBF79rmUo7Qn26;ozBwNjaRE%HDQC>;>SqnrP$VM z6Z0A-*L+xYW%M^~mv71|uB)x~7YDi}bJL-mTn8Nt9GBaNs?`Zg|aBHdk|pX?WLV|K~>Vm7n{X#SiH zC|yBo4&T#Ye=}t1e3!%aDkykiz(72*S{xNWYejg&*^rL{{1GTF>zeSeJjb%(u;Zvo zy%(3E$nK0ON@&B6@pcujtpuw1gmXW(Jw&ni%_jr{okjw-BWW3$AvIN1&;tIk-=-54 zGB<$enG!lWpki|_9vm1P%Akn`PdK&m!WUgeI@S*W1d*NrPenoSJ4s*A@ z4r*UpdwWu7w;!RTqzO>NmR9dG_7Fcr6FaPqG0iuZ_w@w_udbE7eH5#C14v{-u0qZg zz>PquCU-XQIe7JD&PGhUbqu2&c0vHxu}8=Q^tB}(ad%b}>npq*WJ?)Iv8Sjx7LThB zscY8lOQujLKs1aG4pukRMrlpMX!|UcpXA%PP(xu(7oFe^+56=mGT&)_8+7Z%X>s!V~QYI~>EPt~q77eN&E3_y8g??34@bSBGZ+JY+K^Vz` zLqnZd-g()e-_wvyPD(zO7sh)%qLq>r=cY|?m@!V5X+ zuJt>=`b6~q(IpBnQ5_gT(@XxUJ<;J-2}I~_`H&F%=i$_68jsS`+yCoy)6Y#OV9nJX zN6r+-szR6cwgeUD6Au@*mOlGQ8gq-Mv+cFE*ndczSGvDUuaQ$RiC)?L322HiT67o; z8wVx8%PMAP9XdM+z*dz>(<)NbL} zd+ucZOkZRtcY=3h5-+$Pgi^14g~{q}u^ir(VdD@G2je_~3`~AFS6o~?$^Q89;|Nn5 ztg@3k=wi^kz3%Ezg_h@2_5X|Vpkh(Np|6?+SJj}S<6wh>#1S?NolXb50SvqK_4Tl7 zL175aA}``>U!S>|So)_mdV^zT3JzXN(OCbryV-~ z5$IPhPtQ{smmjAoebG-BJd2IlmhL3umPSo2P)gr9obPZ(=JD~%*q1`d@m>KD9xH;y z{SA{NNG2>F?f&I7?|7=Q`%XO*Za&!d;SIstVm9CUnnZA3DXRtif_~6M$www7C#R>T zlBq56?yZ-erdB^y3q|3(9E$Kj9EDqqj)qw+6kbg2*u?@6Lo0s|9*3`Bbhe<*1qh6Dh(ZMt5 zX=4+3ujig38!Z|cuoDs({J$<-xUgGJUj{vmt%vUk&Q~%;cXVU~dZNim;64EBlaIUt zKW1i#byW?=rQkk`VsCg)W`wsmN;|C#iK9Z-bkYhsgaK3(?K(-*uKs{vW~~4J{HeX# zQ*UBKCj-!EnhkL|6(|}cW#U#Bpu>Pi>k2qf{U#~u7KJ*Cbw@hU(LTWTgQ?*ucz(^g zWK7)6uQjZ$cBZ#Hy!>LKR9aBLY`*RB#Lt(sI=@#Pha9&*XCkfIhJ~SFYfjjB1o%2! z${%;RndRz6tYt%g>U8azg+&mV{o$OUf_RKt5^lY$0=K#@&gweg-rAZrX9^VKPm} z^AACGYR0Z3mP9PPub$0J=nNEbRPK_5N+9xMFH#Q#yP}aWJ!m@j?%fMA6Xyr-Z`FK7 zjUBD5LYi%$62Fb9=(v1Zu4})VUbb3iDL>RC-+@q9$7`{S&}Lw@;O+g*^^+B;lYeer z_~2I^9UZ9e;B)|20jQj)1UyuI=c`Umf}t)}QR&D7mCjq%LxD*u4v&>Dm$04GRI}|m zRnW?$au<`fWwZbOTy^DD)z#z}Fh957UGW5M5D*a{dcIe?U1OBiyGFd*;^7tHu2u1f zf(@M~m9h`uiJbOph5NQ|P*F|Dzw-NAq_(!1U%OTgJno6_qE~<*1nR`*@^uFX;P(A- zI9yc|J<1Kn4(FwFt(43nO^sWb6K8RHymoX}#C+zo@a>G;?ko6@=|mx8SWXEw$XhGJ z@!u=pbwLFQX@xUI$a1Pc)W2i?poP$eA`JOLplA4(E8y*LE<=9>)?*+*4<3A+)fk-K z1KMgG;${8)`JqD#xb+EiNSfcF4##8r(gYSgWw{ICThey8y5m8&&7K392-M$vZx3dZ z6(a1p&t+I@?{#eO0WrlqVBo+;snm}3L0}TH)MDXq(#*bmG-x^}O!jOb_(?$5B~GY| zv^#&E+;(5uMsCPmDm`IWeuY8Q@Qa5Sw8s;i#*(OzKcQe(Tx03(>At*oawVGUE{$#C z7g%ry=A>bB#}cH_rlh($4iJP3u%^HV4I>Yf#p^;?Id$L1szv}E8NV2@Ox}Bq-f;k# zRHR%XM~NZY9(aV#TrwAC8KVNUHa-33E>;DiKD-&b8{U$;8eMxvZ%^siYGpa(SFSIB z@a((&8UsBLV+F+i!-ov@Y`}J8#QeU8uU3MCNn-3t-)!*3YJ%HpZ;8v9jkfo! z8PGfpSUPoR$mwK6;UgXOEsX9sHPox?;pS+jQ()aD<=JemL%?wv@9wGv**YBpUOrb= z%l}8q?r>~GLW5@p-DJD^EaDr1*a5@#?1LF4iUT!iwaF5uZ});KHzI`Z05CeC0)%vO zhrco-NTt+Pfa6D8bq&B2H*?Ch3ct-6H9Yq{Nk(Y-?n$gUwy-MLm)?mx$kQazR*Jbi zm&dOP*tw!oatYRPriD!}#d=y(3 ziT?n`M&PLG+^j7vOUilb)(C%)F!m5 zWj8cZ9{D#9?2LV&0TthXuY4d8Sb>iq%2^wC0nrOk)yRZDdHxwJIcBEt)qnlI_8i(U z7|d%QOV7&9GH09J_+sQ00D$0Zc5ZT|q=Afwf=5#CAcRoEOkYDIWa91Ja2Y3rp8vW} zN)-NG8`)Zx-O%5X^Mb&ihFBxjPn_6OI{z&RjKE!4n(;QVlR*3ScKDnFlX5Ts$Q0Jv zq1|!mx{%-lVhTFq-UERG()XOhTgK=8Ie~4;AT|bb|Bu_fbY^Fh=$|>pl3a(;DF_A)#|**n##!O8Ha%Y zjwn!n^marW#4OE2s- zgfm_6fze0tX48;B2J$E3bZ(TLon6Z%cqgrFr8)N!OGU_p(1nCakk@|w*S;=E6X@;X z32`w*IJ-a`n`eViz=mF4cUq#~{09s00xp^%T?d0r+xI~Pw{?jNf-bWVek80WknQP1 z%L&72dk{P%FoMYxd0<9hD*!g-zkC7)jw-9D=m~i%&;>a= zT(j>@kB}FW#bZxn?n{Qp{rd7HNMsyJ@x}1GSPe{AwnZlp)GI6=G>q57pgdeJGpHV` z_jA|P2k4GU608A7c2yAuLihpSm>iDB4ktacR1Eo?v`xKm_Jn?pp82r@6O$R2-JrWm zN2}rL;dB68|1C^A!1n(h@(x*vNrOHwr9OV_*QisuXJq{did0EP&otoTx(Wk55TsdF z8e+)saD>CcCfl}CrET;l4KQwX)VFbA=sVcwsWk{glP97xxo(7<4k5}p#&Six`NYxP zMzp#+de3`c)edfgQhRkdozw&)%bxwa$PBOXXXc8vj}q}d2JIpN zbNnP^pok2W&sqAMRBXDs**8F-3-cGJ<3umO#AdLqw4`KyVc`>GuNq3|?L*VYNgo>@ z<885*EKxdo(xfI!#nNa2Md9N&U>3NZj6A${#lBRXVn3Z6y*`~Vo(Gdo9p}IBM{>ua z?R$Vtu(h*o8Fu`L_#5TG5VzxQ(ESQ94Fa6^EH(eh*W>>Svihm!sE?~d;j5CZB%CkZ z`=A3vOKI~NfC{O~vMA%tuU=iXwLR1ZIf2mqd1WK+QQ~rQX=xW^Q^JNsmS6O!J!FK; z@tseQw76M%s144ljd8DLnLS#Op?`M0K(2Y|kBJWPb1Yws4>y<$lOk-&stW5>IJscl z%XF8OmIid(L3eR}{vJ|a<$<4ly_uHKhZ{v$Cpw_3DJS_9mQc^E8q$LHr>|wh`@CZ7&FJ z-zr^z^AOR@i3~MwzMQ?UE?kkE6Z&g6985KS7Bt<0SDKo?-87B&7361LHnkmI_7ei0 zkGS4IpacRUXcFOJz{%u&`}T1)wH~)h5TQz{Io%68=jrY75x8=I8D8nb!18L*^;AY* z_*}>?)*=(b_TNkls((+x`;^WJ=t*+8C+xh-+Mq;%8Lj2bgI}3*bBnN5U>COUGUzF* zn}Dj-g}_^zFPPs(N*q9%tl% z{$vIy%V706Uq#!b8|On?fD8f9Ji#Fn-aK6dij&}ox6DA8= z#D2EgOzG^*f(^0swc=2DbXNPvcTm2i+#^%>Ycw=<$8O}gj#7A%@zykqJdNv5y z1W3)Md+F8#i4YhV*yQ|l!ZPyg?^r`0%e}<3qlxt%vypy}vfd$r3}!q$@=JRZrw3Mp zZyFkMq-1xOAOiURI$i4$G6iQf77FZ%cOfbZo71)D>yM>g%I`2&LO(l}W1OD<^hEdb ze!BC)=jr&Jj;kCq+2i|7>}jMfWcavo7^HkZ?pRi;SXqIbZ8)@(5~n97CKy5BuVgi? zbif_~QQUI0=c3&KeOqw;aKcg&J|M7>;i|WfkK?Dkx3T9B-by>geQ)5_TFXuUV=R~;M6JiYiIbas zIaBf%JgK2nko1<|20vksUJ*ilPU5>}#AF^!We+ffp<@2!!&G?Xfqx7+)sEUHPNXy@0AU zjbXJcwQsgQVK!%-3Q?6ocC#G3_a9sev7vd9+>!O!S=g!ANDPLz><;wB>({TL$JxtD zKHaNr=c%$UC^na-E4klg-S6uP$vTo$D)@_a|(G z4^t(0&0DGhDxHW6%j46<87d6gsj_`J!kiT{CeO{rkx!BO+IqeBgpc}XeT(-?yz>dC zlj{jKO@Xi}p>4A1EQD?b`T%sG&_wk`8Z~6Ms-uUn{cuK6=LFfihviu3vixq^sDP+{ zV7oo`wuxd6LP!lMfG7x-Wxj*%%kF`=)p~HR!$AS$m#j``35@GWI*X7HZUNnfc8+S= zPR48p+2d%_&sK+hHfc4v!qmzmIPDJt!-e~z%*m8}l8Mc7+F)VKbQ&^mF8I1$Ymu7z z^UF?4a!dyAH{D}OO3?jA!aV^rc2d|F^dyML5*s%eM4mTyj!5Q!I`?SmZESSVU`=L= zxq#=FlbDd+eP&4^CP?jy(d@t!o`wv#CGOC2|NLu6(Tk&wlG|ZY-If^b)rpah3O`;e za}FeHR|2bLgAutUG_{V+Q7fZ0N?pl90dB^<B_W$`b13Qk>%coBGWVO><(iF&qyf$8D8SVEBSM}(XPtKRDRdYc-k7+Etj zFN=$J%?5#S>1)?^^&%c8;6j{==M!Z7h1iaB2CHjpq6D)Z6jP3T$>@6#&&G zO?5snLX22yJQ-P8)qOjWCJ#}$pZlR?=)IONA+MyQ1c-UN;i+RkBZ6B{$}mj{g@q6a zaZdrRKlxhl?jwKgmDI@i)W;D59v3v@r4GuZsz(&K z!yq^O?Blj=jjy-C<^ctp2y+}tA>=Dq+j!(^1-QU5o~aHB18{3T<^0V}2^j5)7)&NdXh=CUuMW9uHS?f}OI1O@h zz3mQt3*p$Y*8pI7}{)`N@`O5zi87ei%+mq=gqYW($*yy6*`U0pcKnc3z zqu0UZDDRM#o*o|$tsuJ;6$57^GCBVc334s}IG7d};Z1t@`H^^?^kL|BV%?-rAl?i^ z9>{LwvnH*P?e9#QNfjb=BD*MOA68VNu6FOQil`D7%gPe|xhg1%+(e zfX!L}7Xf4j9gviSg#(bYU-Q}!XglDK{`@EM|GrdiIb*NAH8#I@TGA|pv94)Ev9B%j zWnuN?ecNKfgPuPDpdX$yNPKgyK%~*`-MbMRhK$_Tup6KkHMb!%A`8koQJ7#tenXRe zXC_Hp=zL+Ld4~A(c!O;YT5FA+C@1C6um{z7%X3l$T;mM@l4v+7`Mj$84;FB+y(=|c zS#1Fk_JZ%OqeGSNbV=S_4PvU=o92&0v;n+L;Zoa8ow`{I2 z?~k^b7Y^B+>Wm&7=8Ivmghk=NZ^JiET3nXwledMeWk=SPC(zl!SDQ31uY=`i56>gv zEZrM4G`rDA5}jfyAf1D~z4Yx*0JHuVv|?k&Iy^h<(bV#`J9HE?(bpO(k6kGq9GFhM zn}(vEtfH=g^$X3>1zXU3uJoXAU@{z_Qqk-d*dt4pm>n2s6Y{Q)!9lh*?h4p29Q3er z>33jvrn8h`EPizbHp193^TGiFp7yY?d3{}nk2|HsbEm|Z7w$7l@|7B%=roGQ9g(FC zuV><_*m$dg;>q}~M*9mLQjcQLuJE3ltMGh7t=|o_pJay`aQg05iVLGY=#7HYsT|kN=8`4)F{$b6A2kVGa zmeaA};d=6>t*vd|RS*{U8ndDn>!-nY*bKVS-<%{t%$xNtIqp{rD{AgsG&Q>tmLV{7 z>&ffgEzkbZA;a+p$Q@#C;wM%iV zhT>A@`@21Eb_vzjT2V{^N~tfkSX1<6Y3Y46Z^Q})K-=V*CwxAi{Ua|MY0B3SJ#d?b zzCIR&tl=hj#Bi_Wf1`RVH9Z|v6k)-^NRtB54p)zXD&!}py;7wmPN4O?X=6hjkrs@- zGbj|k-%T|&0*6N9_V+1b18(0Md9VBvZv1$?9;@f&?R+b`EamqCO;1?cPm z4Fr76n=>PsfE%~h)lZY#EnaoTk<>W#I!3%~*fzpTJN2%wF8wCN(knW}9a2)=m-TFs z{n8H`uuMrohx<^n2pb~^+N5}(^@*YtA4Ji1dRXPCnUtGC77!MC8(cxH9`70b;>%({ zO+W?-vC{IvZKIdS5vL(z0l2Dru$F%S&i?cE{r{Rl%)^$})=tqV5blQYICtx?Zl^L3 ztN{YP$7>auPV8$|m+Z!bvXjjUNU|!J6|4T(yY6m88G$!pj7vzu`>L^$e1E2=$mxW7 zYy1XI!s^;ui0A)}%u{%QRgmE((rGk{Am)0}VH?tVaX%V6H!(dC&}4z}v;~jaZ%W`6 z+1}Mu$tfmrO-M^g=p^e(Uhr_L`|Fo4P9YZ3)j)hPq8t3fu@pzjIbrgOH)oVs?%Xeahbu{PlVJ zEAz3yAd~tO+Sqh~_lut*wG#TSLHb66O5EZh(GT!$;J}J8C|}ZPCh0zQB;aR*hns!M za6T&%AvV6pq^9xnDx`gb~`?wI?6?8wfGe$i@KrAP(%D6Hi39U_cDx_(I()Ui+1s!H3(@4&3lw{{PL8E38*YeQ=>rPc>|P~Oh*?v zVh+sc{>uQitslFP>_Sy*M-ALK99 zZwMp0YH~uhnCka$)<0CQ>@n++AggCl&UpJY@ek*qh>^t~)|Zo-2WxpZR5xcvF5nzF{SXo6m?UfVlKnyw62E-99laAsH6*-$U3WTk~fXB{s@7C+9=XbcJ&Gs!VEx{pY9ab#SA{`s!{3>E) zYNF$?ZtLge%|lVGF@BxVzid!7a9WN{2d|vg4pZgTyIe6k9~Q<}VU2V4vVU2kEn7yk zoB2ct5!!H;OCKkzPp2iZ2-Mh?TMT#VJ_;Mbzh`ZYMlO*E z;KL5v#jb+(4AgA8*&u{vvG9){KhDfl+_#g>V`O=_og(k=HU63ZjW0F*}LZ}FM%k7UT;_vwbrWde%MjQy%k|Ov`<4>T#fcZU9+KT!^T(qzfRwSf&oo& zLuBEW=aZ%hY|a}CxOM2I?YMSfUc*X=ieLT2uzxO?6Hh>3FcI+x)9jCD1I{@IvTPU3 z@`>FM$R!$LAS#C`;rn%usrowXVhM4LJl|_x#d8E{J~6m8Yq1pfs=maPa3bv1;s-?h z_HbdBSv0hapy&n3RGgv|@nIb{?j`)XdKR>>Em~P!(D}v{Ogy)Da&m$|14kp*7ZdTL zv>JC8Vd2A+{?rrz!ad67UP2JE8?7g|oA|+@^0wI;ab|PTD|`t!-t-=|+-}(&vuARQ zkpblr(9TQIds=8o=#j>@JC!8NDU9Pg)jL<=at!GozQEY$3yl%r^pC#HFsY?FI5;#- z#9C!_JY_T9bT`E8FZ3JMKEAIFx;!!q3-SVVb0rV7VS)7wpLA#(7V9RHpm82703fwk z(-QaPty=8V@Z;NuD9LZvf-G8nQ1x6%?G7E)E~Vren+49BDNStnQZ2rjW5&=p5ajdwF)AwoV(2716fIK7JuK8151j5qXb%Z~iqBiAW#vQVYq}Khwxd zHb)$L=C;RwZh?vAftukHT_8eUrpOz$W&H@+5V(H90jox_^w}r+VQ6_>>B<&_p=e5Q`{qSk3PwRicaeulIc*bALbR%$!) z%^I*%)WB!cMkKZGx4;C){d!73WCajV>cx629L6_>fc3HrLU(X{m>WDBJz zMnMx?U|n+$r4gn4wu`e3=-!3QmC1$Fr>Xb6XGH8uBssOqQxaHP-_b!(H4WtEj_ym;wO&)B2z54Z=Nkgu*L)~6^2C>P1=1cz+L66@<>&byaYJfz3U(w6Hf4|Z=&!RU+YM#!Tl?;B0Jb>1ox3yxI(?}gq^m-K z{{a+HbF1n222OoA@9#|;WVdUIZisw)<1lh)@z)U#_HZR7MD6F6J%)xQVwVCy<#74~ zg4wOEzTMdV2`yYMGs539&QF%Wc-%a{0c{%l(wwx^#2XHWv!9~dnqhd&(auh>v~ClY zMUzIR?b%BfpDrJFw5!i>lUF1}ABwCIIVQt|m4TcKSS} z_{_6n$q*-bjF-Ge980x!Vm@JMnDDNGZofOWs?H^n3~JMOqPY~wug2BPkwZXX7Wp`* zN+0)6g3Zg5*L}Zq&ad>Z7_z;>Y~;cXDai;xs3{KQtbfeTH+~b+yJT+y6$wo}TT9Fd zRQq2o`(nSo0+(M&iL!C5O={YK1GpP(*3k2G%~P4^Y~Hh7q_utpVF6lq%M9nfN?q=v zg!PE!tpJuxPHl81jKIjITgOX!TI|{3sZ(4V|K@GW>!~?Cid@-s%&m!(-nHn66?W!uc{kn+=t6~^oVvS(5V}?iQ`e2N_y7r>eKo`OGWWc z)%^FON}^fT*wFmm`XIENU|?EQK&h0Jb1cP&4eG7tcB7A(q<&qs;=qH{T}w5doZJhU zgMsCtn1XJPUtHSqUNtxK!3hDQuKaH60UMcFUY|!fuHi)QHRA0TC%$KWlr^|TGfq^l zQl7_$M<^OQg6Z+w)P3y(z_jtW;Fu7J7TB(|RO z*+LQ(_GEn#dOzM=57w7Hj}{YRb%_N)6^A+gl?8AcVvvUhfKb1`FGJn^f>Aokha(mR z2qq+szfB;=#vV(B`?Kj*VDH2*=Yek zZ<{K$)nVXcUr64vcb;X_tMCT-{-#DZ;Y{BLOXsHRcDcA3|2jwv>CRpAAk4@?0xH9HJY~5o1GlK zM~T^<$+==uq7)maGFjf>xcB5`^umqGTs-Tzm>Za7v2Bta(Fz$r`k*M6ep+ zg6RC{ek0MeNI}$W5BlUb)s5FSfzpX$^W4c9W=#3$IQ+Sn#blR&wecs&F?JRTEUVD`Xb4kbT~dlB--0yLW6t1(jw9+5a6%xondDCxGR5AU%vP7#F)fA$QDP9=56#`_f%tk^vDu@dvitcG4=Eqk@Lxs2akL(Q9Szo)Z}x z-dYRWBy>N3a2g!p5#i$CX#)t+@QncNBbm3Tvmr&7G<<0+=Q_>RWVc%OkOfaU&NPr zZqmhFb3idULW_!sxJbxbftc_+W%SWYrQZIhNk3iw&UGCqeaN9`hs?583rH!r&heJ_ zjguq**;FH?XSfXkMX!U%#NQBbrGtkKL3)Uzg9B!+umD7KV*miFt%!a5ibNuT{$!l< z63+1im66G_ux8nugkln_6t}diFxe=-FQb0?S*mn&TnbpF;<^ti-g=kgjQuVL#vC!S z`ssH6A~%VT@UDz{++N>ek~IP{p{~qNIuubKt?6mNVW?-{l3{X!$48vdxL52{s2}ZM(?KM zr?}{?MWXgn?E3&&anqHAf=r4XxVg$7;j{7G)1XkKZ0s)uf+c+E^JFqg_9?xybDqCJ zYG`ggnk)A&CWMc+?m&mrW0!^|VgUDB)e?$4aZDP2I4w=@+UWxgOW@A}vrq70cFSNmk5P69-?N5&M~k2f`vFb(e8DyL zI}y#K%XxLAt7GMx%;Qe$ocrz;rKO`Rl29-;m7wG_1lyy(e{bphtJS4e+;oEb^v)o8 zg$oqitW8dXw?#fQ{-B`s;Ob{V%+D+Z{R1uG^fw(Gx=&D4BZVcP)d)u5X75m=1xKMb zlT>u9Onh>?R z@pUz@VrSLIvrZDXz19#H6MHMUoc?smd^T!xD|&jjP#fOiSRIlN7^V2Of=YB-py`h; zb4_n5RSbLY^OM0F$Nr$Lsi}2l$kKMWI#RO}2Ki34hbD{2xvGG6hGl! zDLJJsO8P|$@@(l4q^9I|^3Zvk@sMD+iv*Y!kff!OD{9a1Jq%y4Gv&f;))sAQgrAHy zg+T{3tJ2!0pPDPuqJk@eAwXwYLv7g*+98MNQ8BrZ;KZTH+f8CYOgBbfL@UOLXr5XXbsTg$?C4Gs&B0?HjY<`Gkj8A*M?g-^AlgjKA1a(G5Xy}>l79*%;s_w zZEHM?e7mdwnjzV@_N}0nM$&rchZ(MEAmn8(EQqJ74~#yDgFL{Av+Uc3ijGH0kwg3r zGHd(+PL5HZTDTn;52NL;mpC+uKE^zhg_)*gIXUJKlG0c)w%QuFiR5@x zpzrckUAE&Vlzm#ma_ux3Z$0M1)UVeLlzw_S<`p@ff2KLZKA@Q&W5}`e%d;Jx?l2MP- zSt6N!F?xnsHIl?K)#7lZ>OhN#2A9Cww*sA?STj%BTb7AN_69-#c!7LZprkxTn=ah{Lj)g+i;1Hh0Ch*)Pg&ywCR1M8PaRGu= zNh85&+4TMCeg>JBQWuKrSLQMh{NP61y3`fvE3z$24cZ2Thu*XKy-jnoDbipjm+>`^ zj`%fD5Wsd1m-s3s-ks%@y_8sbu+mR$rX#hvqe_^-o$|-Ql#!kgR`rm)ok#y2pP*A*n-`1P_ zvE?13N{w3w(l?Gr22pg1`>Snm-ND+($r=uF;x9snVtE)8*dYH;QGzzJp`ihykPHxv3fwOTrKJ-K zs=d9rnBkOb@^g57u+_1et7qQ;KSE8_xW~AVn3Ntr5mM zHwkpt_b}KuKw|UOmLU^yiGi#!oCbg4fdj-tw{FEr?ClXVT_5#50Q=*0Eoh7$T$c|( zFdWca0O$bRHyIMo!L9_ZNC+f_pH{Y(R@A`(w8{r0QnNY!l+o!n0p zF4M#XedYo3>3EdxX6({*!fjwb0CvueO{pCm+geNla(ZZUtpV<-d0=a}DK=TY-=+s< zK_ku`0sMv@jAaR zO}J%o6So9f0(6AnSaZGSv`YY1S#z~JNw}8VAb0WnY&FU}oXP2=M+44L7G48Kn65Mt z5i4O7o)ZLS`ryKk_|l8vK$z#4KaPLE;0u&?%zey7d>#r$4X0+4El3OFwG#$~mI(a*>h=5Q_y51211DEI zsD|_I8TYSmb_nrbBL#{ER##!XrGcO4r{;AHfia+Yb-%)_3qR*vY|D_fBl0ui#sBB` zKhNQxb@0zV_`l^GI0wDsCFm}YgX!)+zyEm-|Ez<5_Q5~r!2f6Gz>?6tefKToG~Uj& zr%fs$vc?-G|307X!TSq8AAfj){Cb@x0>W_-k(zIgA-`Vz^6v>n<-fibSMMF*tNrIc u{lEJ$A=ktRg~VH>s_;LyBh&ge5tk2wzKcg?4s=mDvrB=nF#l09+$_ukL$voH4b?)WC7pGk6l=X}d`UEk{@#z0S#{RHm`78Vxv z2lww7vaqm%|2#VV`(f}hTG}zo!gA=klZJ)?jD;mIE%9$P?IX8NMG0x#9F)z@j%-bI z8oX?yZg7%I&JlU_PJQS6?>497J7ad8Xnzd*{~7mtfm`u!;(zdk-1;0xn&jsB`wY_B zKO-z)Wb*u95*K6hKTK{+XrUYj_4MM}^+o2MBOl?cs|uFM62)Pb)}g7+BbM3}^BGjI zg{l0vt?s|e!b+nfPf9>xf$-6O^luDF#bLae$92B0K>}|1S zu7)-FBc#r$-W{_RB34S#&N;Q~zSnKq(*Af?#$N}=_VLRl2c8-Dt&)ka6FgnpA0?@6 z!ohO%9cRy*tg$8!?@fMerypW_r#r@vIsY zN7E%nv$C>&1INg6;oj&!uZD-OGXH)`;?No9>t9JM%!6mSqs?-Z`I>#@8S~g#`cB_G z1YVvw9{<4n>&>d)44AJMp8Wgvf4A=6uwmxHzbW%?xnRb@zoqiuHUYxm-xm2VJYbfC zf8oKu@PHWy|H6ZR;Q=!a{)Gqse}@MqrAqq$(E>g&L*iek@Gn$g#=*Z(;s3)>Ved`w zw07i+CD)0+$39<70LR@0CH%$`ZhV`j>igy0sdQGfg%0@Fg)5rR4zCMWX9?3@n$xyp zIRdW_UkkYX1O13CMoWx3hv+S;0Okhf!?;wuf4;<7Rr+I6&I zzO!;vXo0iC5EbaSS>VADR)FbEe^5bdC1%}5lw17BD;)E2DRcWmSpF{HNBFL^YjE|9 zUZ%3&@;0Hbudmlx2B~9YYrFrFyQHMVIt?t|Q}EE4XGcpg7z7?2MhOkyO+0m$6|Nex zK5d_DIXpE**HG%%>>7+=_){Z9w=`9pVVRcWsa2b6KNenZxdB~EVg ztuo26*Bdo4G_=ztgMBfd-Z(ggMz4z|ce;&M*l+x7Jr2GRFOkmDbesF_PuqAkd2^_X zvvW#H%JTBESgXujYkp=pb^2>WJ7#QVgT{a_LL#ZC8i#>O+YtB)ytJ@1E3ZTu_r5FC zCQH^&pRCEKA!9opP}0<1*uQ8sUWS`lnc0~+n|a#1?46j2eNo0NaqE-(hfXvd9>U`> z2nM|)5UfN%Kp>U}(&JEhQJRCDT|miuZlv6H-djoak*R6f<4O@q@-oC1@o@#G{lLj+ zjXNv+k(H}Q!@-a?zM*SvN1xQXt&F*mhw(m)7^rT?Ja&gp*`@M5DN2ee;o{;- z%rVfQP1LxVf6A<^FD)!Qani-bDw$PJA$(GtukSL19|dQDJ5J| z3e03qUCD1c^7+)x?(UqR;Dk=s6_l`D&*?jH5Cg*E2lv#hL8M2}{4Ynmk#9@!dQb_#v}oNui^jaVKK_H&z2jacm~ z@Av$@EFpJ8n2VKOx2PXZTN)SQrEXG0aYtBKzUi_YeR^?OIjBN1W+>L3c(Y*)G~7;wUqbzj;U49PQL=|bNHi)soez~5uRi9(L!vL3U5-5-NIAs z=l8LV;-Qg$YsOaz?L8qvG_^Gnc`%HVufVYBPS>?{mg|aHVjS$8qA~$$Df>MHKR>_j z?rt?TwaDAnxKhh@PNI2Aw5B2q0qE@TT4K`3Fh3_(!jXP^7W94@H<=gMl_9fy*TPI~A6firl>uwLI z;3++o$zHc?Bg^&u!ahjT(UMY60yKiF3X6)0%E}`^}sZ3 z;TkjjMYqwu$^z1kj)<8X3GH1sf}EWTrT4yxs=v_+!!-_yx4npJOIHcVV0;%)@qf`q zMW*8h=lctEl&JNYUjZ8fxN z|GDmHQD&$s%xSw`?}ZR@W5NepVp;D`qwaA04#s@h7oQ#eJpJPJV;BsosHmu}t}ZJp z+xB>!5~{AF?cmOin2l42YU@=dq4$aF9ix4f{mL+9+g+zt{0^O5#x`M&doo~F{UxBn z#oKvq`kRdTN{!_z7wbz}BUie$L1GWBJ0F@VmaFlU{k-gT;p-yFo#~9En3$NHoe;6F zn;}+Dt9liNyaHVaDmWQ-mEzJ;4sONOkEhM!1=V=zdNm&S(&r#9yiVObWafFB#S-oM z`2NdzZ_1B1!al$C+P1xplz!i3A-*M77S!#!%@(s&T2EAl24s*T*>TY3}8g_?^bx7VD}!nYm0 zmzsChj~`Xx&G~U*VIhuBLGp3MW!c{KweR1*H`75)vABAMy1!2*;SsIerfELk#93IH zazRKJv#x*Pb+q^Xmn7-1=V?6HA$Bg%{@ZnoHR#Z*W3cqpp=;zONJ*^ zCee#FP!=*BLr}|q%RRbEkorB=$=Uft`(|1Id;D3g0;G0IPtfzeQ1teCi~i+Yf>%h$ z{_fg12uaLV?HgxD$B~909~YMu;ds@z*hLL*H7{=`lqyaj+aVG$0LH^l(H#?hILqTK z2lx4vjh9J~+UpsdZQOYY-jeA=_8b{_&zTdE1}sH|8PFirGfl#puUAJa^S$T0r_h6S zeo)GHg+@l65XP3!F9-dR{~{FWo-yHBOb|~PrMGe=>)VAi$70!_{>6dLemiyFU5)I= zZk97Vg6YdsTnOXq3oQe38W5O_Z|8P>KBqeSN}5*gJ+0jS0;BwXg0I=~s-Je-_wN~M zfqUcgkYuA=W96;Qn#`U&$pcJky}z2Qsw$1Jv$KP<3Ryeq;eSRxQjAyKK6t*M+%PD} zp55mpesqnVPD;u3KcK^p z;cY_7#;U%GpuoJ(1XKF6pNz$-od)tim+$3DK#muax3|08pHw+MEHFMnxB->h`NpL$ zD<>ymSyz>nHN)C?-J!rjH3J$dg77x+Yx68e6sF+~!tRA_?XhBMu1qlL!qfq{EnNErV!d`K}VeoW{3nYE- zYe6;YMm(aSv60pmzS<_ndOxb?J$8}x*s-3BVLV>QA&Q0iCr`tLZ@r{|MJ{Y;-M+9T zD9A9?L>;JGyJX1&oB%%__qWrSS^bqe{oU23lNC$5Lqh@@8ZGfq+P5D+evFcq*V12q zyciL6H$%}Y+q-S|LTYzZtB1MYJlY5*{w{CfOViSiIZtUHmHkm|L`Iq7WB!RrA?c+p z{e;+*<@V$}Ekc?~D`=Bj=`1Ijns-=1hfzUc=sRT?V%-3>I#Oo=HW5bpd{NyLTyDyV>SsgLddX`l%#8ku@8cjA-mi?Y-d8`hup&aT|It$ z2)ItmUEzZXT9KBQ#{i&Ot?!q)O}vh65ceGcVYxP5iyIn>G;JQc&BDIVKC4heKgh3V zWJ)d4xe&qL695#OnyLJ+CM%EG#K;d##UGsR^kGmn=2+Gg@&eEyfh88{uI~<7x-ph*cEg z5;SbBC&G4Le$dLx@t`22C$#EDr6Xm8I!Ybw@2~J*8(TkjdU0;fE+ByXMp*Nnrl!W% z(1VRO*yh{h7sr&%R>xHiN@%w6WS+Xpg4Kn(q7x6opB9q(h^#%Brlq)QkK*%4jt?{D zpsQJUj)sNN1`r+(3cXxx2e^4MWVQ$IgYM!<0PW?k4qq;Q+M>vp!eCnB4$effUP%SM z+k~xmFt%cwAr<6j6b@nu2_3a=D*Jl3vd)!Spri-eHKI8p^Q{+la4={HZqMgcViYup z$|*j}BZ1mUUt_qq)zxhJIt^8Hx+q{mH;UAQSIP05YvQ8wQ_aW)9c{DS%uIR^cAVU~O${Ya0;}A>1M?%8KoB*UGP)_r7*;e73-l zKD~#Q8{*qPsn*g;5JyH`t^H0yePRNRK=6HB4J^l}x_@v_M6$4ZMdS4&IF#IN7u_#pnNA3EY@_0yG2 zuAkL7W?AXbO<^xjd*{y{?iK8+m(Ij0ypqR|#yH&y_ZREAOA4)gwzAFXn&f;zMN0d69D0y}bLlXT+KrED$5%IR zY}n-mMjcD(b^ruM8Mwd$|mKd@MA5<%Q)(OioysV9pNvq$~R z9Td_BelvIPO#1y3K#+L^SHg3GkL zD<0>sajYr(c?41^&-Ky8djpBONF9+xt?sGA>T^8?(3Rz7y);FyUBQm_wAf=TEMEqh zYaTlm8qN4QO?|r=9=G0?EP7&VDf}%2ERK>d6j2O`~YbBY>{{ z6$AQ9t{Z8oRxF~(+(QWy-jm|?r+%E8w3T3qwp?1H+`F%1V`IICL`6izbk}Y6ubqu; zX=zFPiaqGUzMxn)*nZ!~Gw4#`?QuBz@*5Scc|+3N#zz0%Dh-bfu-P23vko?mbxuw) zv~B<9d%Tf)6!$_6wtjYv+d3$bGd(5c#2-Gpo`aQg4k^mKHEKf&lXiT=ICS7zEZoTH zJs#9+@A-~4!4OwZPbt~nQ}pkqgxbRrOyT;on#CTQ*+$Xw9rQ*zny-=4XHwik8ZGd&`Hg%w9bp;2-Ke z&36QrZfnZ9&9DqwFzXqrPTtW^V~c2Q?|6fLv;N2b!~{jJs&S-heyAu@NG_=dYEXqj zQSL&5AI{q`*6()AdykAO4ZO^0JxOP#oaD9F0KA_*+hK{wQbD2?_ctx}ClWNcLY*C? zEcg`>E&xIMVAXhz0YSmt&CRmTmq78kO%0_I^2KLOG1MUm2?-u*+F&QoBz<)x!FroA zkol#U_;N>1U{RmimgTWZr0;hq!T?<>?IK-0`*KG3_J@Y)ho9g*X~#Q@ULk&phF?N` z2?ZSclNN06ZQhLVaH-|~Y9%(xOJ5(`ktmLxsw0)?Uj}P^==s}KmgLXIVu`xC47e4_ zf|`MwNOSvh)YzoJf^92y{`O}2Nvq+(!50_K9gDm@^F~vUu)3PoI1Io0SA2Z{sg$3N z3jE%xzO*^EmbzT3WMthUL|jQ6<34?Po#VMWV^ZT}Q^r&-g<;{&tt zh}Aid$gdrF5*XYD3^Mf_nbExgRZ`y2LCbp4hh6KI*-J>l>$Z6MdbIjMEM_lO#een7 zNSmCzy!&{Kglz9z$ocEL^anVSP^M~V`SDI~@fK(O`S_ATVxZ}MnF9PtOKes+kxAe( zb5HVGsvg*Nn(A>YBfRIw@l|sYi~8pzIcP#y+Twulz+w%?w|xa)zNyADIkdEdL+@7G zl$*&I4y@0NX_1SsV(;hYGo+172;#)aCqs|?%4vW=hE8|3!U6*%nal(s9Wd!PcXCtC5_fBj4J&N{GL#%(qh;XphL>z z(zM_&a|_n1j3w6u_J?aT0>Ai}k~KKhj7>~--X=F(&SaGOECX><-hCo))IK?~1m9No zHpO?+FRm770qH-4GOos@Hx>Yp>9+Pg;{a*E3&T@iD^$@e+^CeT(aKTRkdTnQ;x}*J zbc_-i2c_S?i$Jn1O}Qmv=^+DeVSANE0nr;rM4?KC{K2ztYh@i$97JD;?2+>s%Y>Q9#Qe3m zWK1w26)0b2`ndT{BUaH26lPzBm8!weF8$&&)St4Oi6P=ZJ$)#dtih80{{8!ulny)T z>gwvDp&{s!2}Gt~bw{_h;p0Xd>xo9S$N`}Ixr(LgnsRp-AwL#M)Vn^ckxN&u<;xo& zaEm{B!KSRFq@=EH1t60*`vckJ&d8%xmtzs3eJ&XJb;{>v%X}iuA8!h}f<&2>!0B}# zTm67I{!bQ9WWNM$cF*@(ra~HO)^~e)dOE(?wneo<{lD@l>*Cp+oVP?>t^6mC+9mKx%BxUN`bwfp#R^*FHv!(=zS z*-yvy;iYCiL4crrSU%s5d2&BZ!bH0zQ)tQ3`jDJV4}Nnjsdnh&G9AsxShu?b-807b zDJNhb36)_j2Oln1umtig+HT<1%W6%2-K4|NpHE6Yhj*xURCeG`B?m%I4bx| zdrIiw>!eWqRz@3r3Dq&6#dQ!}svmtW*v1j2QCIFgiQWC)i7m~9*Eg=cE*f($c_qYu z5GswyH6mm>Q;;xo8m#nFJvB|Ynm{$Jnb_bfq6hXhZFAqnK9yUV`i~Znro>brLw1P| zxQ&dASULDd>H|FHoyZAl_{9e_u$bgaU`*)gv>$Qadc9*5X;Fo15!nQ$mESKm z`X&gn_xT<3n*eZ4Pfw4Gm<2vDHN{O#Sb)Ng;tu(gSv%=J{#rn#dAjsG&%^`e#Gbsu zm-EfWPSUW9LM2kt3Y-t3j<5-o=Wds~Q8~H_)R^Jo$1*o^jK$`?QR@oGcDrVnO;90o z#PdxL=*v^b2|iDsPLIMf1r{DN0TPn=fTS*jU0^RD*3(pQqVO~&!_SR=|G~=y2 zUG8VctL=_7lBf8xr^3jS0po79-Clc(uLeQi1S05e$p2LY9f(eGFR7PL5xxnkGGM^( zHG2v&0f+(fFJ0Y%jBXNm6IdP zCYG8cX~~-rrCJBZL;bJzNf<^N^*q%n%i!wS*aBm*)NTjq0n>wGZ3Q%#a*=%zvad+c zjeJp26%UV)Ef8E-giSdkwlVYMPNi8_biRV@WL;gI@ZIZ0wgB{2x<}ZGxp*pQ=HSSOhlb=r>bDG_<6~psOb2>l@H!(o&D3e(A}11tWOWo z(=oO0mje@img)Y>;FMsm;^R$;!Grz%{e512EmAFc9jNt-?oqXaLqmT4{(`E3>uFA{ zHXGZo62(p3$EpZTZS|O;p!HTvg_na7I%Lk19DrpiZU#^Qf$hGUF}}X;HztJWX&*H? zDSQGbPdAub3}NDt2ry(AmBC|qtBw3pBEeKlh7uOVrK^4yaKk^7C7iQ};Oe6~U(4 z+Rmy>ZM-I97&Pm#H8)bI<;r+lr)@%OjB8<``oMGs2?WX?%pPDI$u>4Ll-MJTEyyFN_-(&2&x6kR`r001#__K+FDbNb?|^E^!EX> zmPv-d!dFMir6hh8(9v%}x0pl5TOtWeKJ(94zcCgY>g)UCSk`k9nZ;)8S!`uV`VB>? z-f);7R>rCfLieMqSpykmM!hv=jf%okU|U2ZT{KWS!<*B zr}C=H%pL}9tk1OgOvn;Yiy5ha6aI=gu0`-hvkUycA8BgflAizB8e7)5JNCBYHQ1>J{*zTjqG`@VI+@iF8SIwOn#12#|q%eG-@uZ!L5$!q~RKTql}CpzswqwRGl| zH!-)|=wifh_a{}L3oOQPiMlszd>=iI)N$xck^uG2?3@sg>wz=`YRbC-S9u_<0J;c_ zHU6t+^WQc36x@po3V!Ve^TZ|D>;2bG!~=M&xZnD>ih{?K1qyONIG^Rc;Ee)`HJE>e zV97&8zsAwbt(f@(gk%-i_~<2+2Ob@&p4m0A9=4l7x}}_sUajbCP6Ju~?C1kP9s57K z;6V4OTlq8fEW|35wu2+@?)s;zj^gk_T1$h2#s$W8)WV`92^g`58SN*L^-V`SokqZ8 z@teyayTDP@V%8%s222Glrh5c+nuB>wt@n`F4(PQ{pKrNbWJ!L8T7ln1S@24-@6PF6 z$7BULSFM+l8*_{$^Nf(c7NW!Cum-3j{jGY>ISE^Y4N>9>jnuXkolr9;O7BdZX~h#m z7k|2r>r0|%TjLb$4a29vb6z(Hzxdm9e-cC&2I99k*2Rh_pamGw7de@Gun}^BW$Q_nDnK}h)m|c>d(eY&3&jv4{2&sHBKeLHe0SYk zh{3umi1#I#x5OSy_EycB4o_N|H(pf@^IvU0GW&}?UzY+*|7p0GP^v0Fbz!P}(kS-; zvDdtlpP)%63wt!yY~-b;z{a0c%XaPM&y+pNv#OuVcXtgQJxZ=X=gn4k(ibty2L|U5 zLDN$_gJ@6XI3R9(e#&ICL87pfgCb%)ZDxb4F25O{72bnZf5c0nPSY?0lpe&nJqx94 zYU}m9@;WYd{^xd;ul!b{ zp5rI2MX?AQ)H~*9{H+M!Z_wPx+zjx(TtgOQLhFe*k_B~llzP7SlhsZ}B4)QR5z6b86z@j2uhM7A|oBK*iUu^81A3UWESnaZiS_i zlc$b_&9>?531-@Y@D|-P8aU+Z)}6;*Z65B^Ed+ZQFtjz^F>hEPWey$paS6Ad7qY?y zYJ(td`g0BpLlWWa(^;>J!KL_g1igr1vR z!vq2ANk(5Z=W9~f?ziVX5pDR*&CD?CmYpJR0fMmzb7fiHK&{&Pq1#2Fjc zr$H|cVxh{EtkRhy))Vg^SLM=ENUjegN}dFDW{=}1>E>=Q^yc%-kOR3AhL`S+$UjBU zhxtMGU55H`)o7YeBO>EnCs~NGSD6z6tDe*4bCK-KExe~~8`75Xh18V~;AY_XCfOv{ zyOJdFO9=%-N|)i{pjkfr$iTqB;NZK?d7xLboRtEj63ToVPow{bkuBgVvJU;g$U1U_ z)6`VXIJao4wYZU(|q9cX*_;zr`9N%YEy;~N-_-K%#)iK|Gux=-=a|uV4-p6%`di9Bcra z*h%8rTzdkL14q$eAwE7n%mZl_2A@NEaV$7&ptDxNg8XB>XHu9`P$yHhMjJT{wYinv=Kkxj=0a{?6rcmQT ziVH6vALuJAXU~C04=MzbS`H;qB?Ym(qJr6|!(nQSuXFA)CY5|R_!SKbG68NJuW@s6 zazaiTD^bEoQ;qq0>0-&9*6TlcFp78jnY}0So8|PO_lt&v%a&cq9j%*6eE06%>q%4M zItCzKjiwhCl{&k*vCnR7Y;@Q$mv9CwLE8k(0qj2m2UcwQloil&m^0l( zt;h|~Wq?x4+=|=3W+KdXxvKcF3&1j|9}bmZ`+XXUa&vPF3a%lQY^sdKt}qkyGQj@H zA5Y!fqJ&EO&_n$EcKBrm2)4?zYO*gkSaQx_vIFE_I z**Gv}_^r35rUsZ(xKBiumX^ML{W^B-azmJ-<5f0ZUfzBu29w%;{`^r4!*#eRz6n@G z_?0cjdN*jsKW>ZoT!!`YfS*_R=l25Am4mW$Q8rFS1%oMCaSk%^mt~Ry|a^EnF$_LQz)#;5ZXDP;vMAFjCoEfa|HC_VBZrI z7pURIq{g4asBkLqeoWSrU&ZUc@RCe62Vh`xz7tG>v~&Bx2oVln1jCHCg!{p(o1KMp z#4b@)udM#UHX~?wG`Y*JLbt}jsqUlG1HQVyKaW;a8=yYjM}4tMgdJ@>5lrANTQccP zjO{#XDrsf$>E)wq1}@sSF6SQY&vQ%pc)VM^y~}PlETje>uyD<`OZupU{lYd)zO0=b z)I&~#pQfMNZzrdDxU5{M_h;AF(P87@P(N625r0(d=;TyTT--J9ofVRy>{oaY*n8(v zx!X9mpI_3|a7})73&qgOV9S2lCKi%`odr;~=v#oTYg=u;UH98H(ACxbD+@%+I1po& zvaA=F2p3IV7R?B>@%61TxLVb%xaAy1YwYXq&(;=a1T70?PdPsDZ>{Ul=E+YMdWZ`=6=&+6Yn_L(uI4NV|QcSOj!45yhx z;eb1pf(blWU2Uzx9V~E~6)r6g6-9$T03hG(e&it3GZA3t3)l6{7@tzf(+jPl(G=v- z^GSIO*K44N-E3geJmE^d^AhIrIR7=IPF`;AmEU@$1o`-YvyC$Xa0^ACe1z4IAcE(= zl&JeCq7HT&#iBS(Rw(zH^DPJYP$@*{M z&ss$(v}A2|N-3qC2A%1Y5mO?#4@SeXTnb=Q7BZBi)>psZOH53Jqwxu(rT5BzbWc#| zMWI?@x8Ba?1X%Z`Ib;4)O%^al-=NEGID0o?m+l#(9qqZU6W-F#mP^WMNnhF#pUZUFApO84ia6lVx$ zN4;RTwSV*Kf8Y34D_7oa%vg{Fk`FkOE(T;MS$le}gPa9MftL;$=j*`y4Fm&=WS8VR zOBV!)Ie^KigUVlSrV3nVK;#O{&&$g*xC-d{_V)HWTzFIt_-4dVk(J*tLAye{BSM%f z+WixP!Y_12B+U;@X;?OFkAh9=p#oB0Vs0uJ|N6C{ioYvxEnkfC_xA^in0#s@Q+2~I z=ss9W(;%my&mq&=M!6G_>X-OWaP=qym*KNLK%?FVfJh&!b^(f4vb=&qrr*_cy=eL1 zNk1HIDW1O6a~`g1mS%-ktygBFw&{fz?n5NE|6p$VF>7zdDs9AW*lw}KySN!!meAnel++f3{#QRWLUwjZ&Oow}GjUd#pw_8nN zr-Y)jvoFaG14+wRjC*5ECG?9YWAS7yMurarZWr_sux%P7X7{Hu7UM#A(c6%SOqDH~ zh1c?%87w>@vCO$)hfv<8lCPH*2$rSmY~QhkRApd#QsBZ^Fm|Qm`4nx z#LpKZsT9seA%Sff^7tgy%xljhgz-fcox6i+u-_Q(Q5}2##CQCy zkfmw$219Uvr}w6iVDPm4PMP@|D=-C}@}^k;Gxb7uDwr2J=s&J@9s=P>ULBQDR6G~F z^fiLDC3qu67fJEtS>5Z2@O?!m71Cg z>`=iDwCms#>!`~`MTLdUiaNj?5d)TKw#VF$wEogJ5M4&b{fYj~VWow}EJ{cva#>1I z*=zdFcnnhJbh3qad!)NZSy9pWXKRh12_WBqBmcY(it%-+NW&~!2V3$>owcZr*v0pw z@1G1>pp@APbE5j*^htdxEAs%p{@Hi)oyqwF1K4N+fp85E0pz*qPZ4m&O=q-QL!GE} zvQEXN)WFvzFAEI^YU@nzV5iotjEoX=dUh(yva_>8c@6+JX8sz`sUyst)e(DBc)v@w zqj|*9%az#PLZLIah35u9P)9|O_e%;U&K)yCl{ebz_uQxq+qA>)wM*Uqm|@weq^$hp z`q@QAdvEWG22(@%RNc#`nTdBY4b;JApRh^Y`LFPLbz~|^fZjkPQtYy%xR0$A$qx+= zM|}lQ0tUnLn-Z6net^>WIdH><`9nn)jxl#Jqtm6dKwB>AR@?els4HV_OTHJ>dglP_ zk@*dFaKP2hCg%WjOX?%<$_*qtU?c zP#ntGZ{dU<^u>qEg1CP=qpivEnZ>QR;b+Yi61r$bxUWmO7kZv9G^*n#Z;~#qr?+q~ z+rxbt?d7{tG>qpxR$%N51h9XCetC6ul|=IR-@-of_Qfv#^XDlo(1VtlnHj0A*D6Fs zQhv0C``-Xgnw>J_q3Wx!1WpO4t2Q3$i`v0rX{_Sn!)~9Z4z@>#1^ssdA75R_+q9jJ z4nG!S^HUt-|6$8#)&sM)HXk4+CI&k2?Un0iW3&9Cj`3#0L95WzYy(H&l!pX90#NVO z_`@9U1;POYs?1b0TMBWVg9$D%>LDEA)Rh*Ps*;~#;(djWDTXn|+n0v%Fy+vXRck3c zJ20>zQXU|78$2`cEWEP$$>@feg~U-muH23C=KD9q91^&T#D?*MdtoHAT^MP9kB3=) zl^4eGbk`###l;rr&>;0pAwfa)6+^I3Yh%^Pmi~`L60T`tW%!l?y|xD$5!9FgMhJc+ zC*Maj`b5|fN(NU#v_Ct7Ff+5y15bigX1-tQ@HOLKpd6+RWZ)otzB_DV)nUspFW^BGW_Q5K~jHNb(FmP*R@ z$}CPO3A2T7An2j=5dEF`dI!!c2?WD}bK%|St-|l-9&Xe(>x8~o>`Y7KJ{0zamOct{ z<|(}#h(tU+Tj;~vIx)y?rGyKamN6)Y?i3*P;ep?JJ~9oMmfa8t1fWB}^7!rB9iZB4 zYj*;;$;ZnpCN7@PUWqAUUI!S^_p8+nCvDiQZKv-P7`N6W;lda%1E%U$qpkWvwO!V( zG`7-b+KHY^Bj3K>c{gEJtrWZgz8-*Ftm~h@-Ar41zcFZ}&(6u2EVG0(EEojXP(xI$ zHoPZeDu{qik03{SSq{A&H>GV}8A`?qT0SnP4#g>ezMMd=MPwU`P2w1)=FVkq-BxEn zN=bP%oFfe$nZ7#@umCj&O3mT(XxNz00E`omWk3;9($Z8(sEA0kH%%v3tfw&3KISTA zI4PjPm4Fb#9JrET@6f12%yzW(ocjaIx6$?Wb>d{bxVU%@(sCNaBPgzb)q))f2nYZ( zddCkrIXTRMSB=|v^FBNs9k9JJ|8=NaAp>AvCUO*93-PpZ_GFCM&gS+l-*E#RwdnwK zx>M#C-N6UfHeB`r_7LE?EG;NNs?|9U6#}^(m>}T5y|mpM!O9tR9Bw&7C>QOB;N!JY zYhxYCUB(T$kK$`(Ue)I-4;#C`0*iew1VG76SDp6_(>lI@eRw8x!dj}lp1BA+vBX^;lX(F+;3(XK8ov3UW}`k)0| zS)7`(0vDrd!9ZmbsN1t$8Z+N~FRA$y*pw>DE#}`f%7tfMdZNyRS3!WdUdVXaY7=x9 z4mhrn(EvYlIFO;IZ`uina%GLwlgBq2b3=arz}m)n$46OtclAYHSO0&%d6NaO}&Jm5vAmao_ctnAOq=*6+IkulU&_3hi`_>X?x1=@b|oPX9S=n?`=wN|>S+epkrlLjL!=Is&R$!O^wac)dmfCN$spwqn%v z(i_Ds0}*7HOXu4B25l{KFL03*JrT6<;)v}&Vs;6VCKx30>ZEY4scgdy=J%?XKRm*+ z@lHns&`!;J_kiRNyhcpfJqrmWnECzrmbA*lj-K}2eoQEo$w~zGd^r84XpZ=Z73bRP zMP_m)DXPl;A8hBI2Cc!3UlR8V+UPcn%@9&!04QkMd<>RRT zXaT(_FcET<4_SBtiV)zP*$s~mm|dqHvtQ2~TcdEtCH!oM79^^1JB#)8J_2yTLapHj zhdwf`x1W5y=ejxYx`L{@yY8U$ETfvk_j50K+6C0#o+1-B;Zio+}tqwX0ivGvH-S~++ga`ZMALG86MOiJEk^t!{Xe1(SBWIt}Y>| z(qB!C(P`uOfm3bUfMN*7tJob{YtPsHo-}{opEm>Z!A1@zjhCfOxu`S2(7FIKkKrKc z%xm4lHrqwFC-3|ckD44aX;W=vHA}eetaHT{S`c@}Uk{#5GEy(}UEi&Iz#&8Y2 zj03g@(;{T3ygG3LCG%S^sLmFjo`w4ef&yHWHL>> z{I=p0WE+AX`v;XO`gtBSLCImegT3g@IS@bNE2-g9Pu_C~Nlq{*&1xKyOE#Y?$9#VS9b%j@I%c%3(g%IeL<2AAW zRO$waeuVi>eaUES_EB&@30yvt2^c@LZu6$wfd^vM046!$c2lWUV_kXq#su|zx2x5e ze)x|MS${{B^Mg=c@R^YO5E~DUJv$7Fk9T5oPcoAHCV1cJv z(u20T6_RUxp{C`=UCG{&hT*~27AdO&wBR65@aT)f*vHV`&DR~S8s}DpLR=eUd6`Gq zP;vY;OPZ$R2f&&t4^=gkJ(QhPI~grXhN^Bi$=K@0osBk%G4=^2n8+zun3L6XkyqeW z|4(IEp>eP(q;OU#zzh_K>2aVIHXWU0=5x*A2XUoZ1T#)%|G zS1ED-W$S^=tp4N>txw2mb1UYKw|(nfO;L^)EWsd(I$jR>STL4Go^O7*R>{Pl(?RxJ zmstY!a$BK)-?z_4ID%Uj2d2Y_Z9iQPS1pD7(*)N`ArI%N0g?=qkoi8{x9*b8h+4%~ z(_VtL=iP3<`2+@vRGI5h%BO~VUQQDbW^MT(_ITi(JCno&S^+G#&R^%fw%_I-eapUP z#D^x4<;jme_ekolSBUePf`8E|WC6!GGX z!^5wF55x)sRG-n3(N~tl3Na7u>_3UmrPt+|uM{Oker+!hZfX6hBfSr@zcZTX!T8pu zf`HET$Sew-8)xuoB4)OK$XKVP>0q*F;+RDntAJ%W`fSnDF_g6J5%aC4j&I?8lCIq_ z&OIo!LFQG6H^54FO@}SW0HZMgqctqpHdvgD-&Olk>fc%M(!?mYJ;#<+`5s3SF8p5Z zk_>cr2%dtNo(dtw{paDOl6xhiPexe(t@H70Zt6nDMA~CR!B^ax4&nL}W81=sG0Xc@ zhFHV<_q-GMS|0{REf-WTqfUC;ra6Pyqe23gW;oe>|EI}dxoVq^G+CUExnp0UoFbVO z+1I=@k-%VVRlr1@v$PvFP5zmv)=+3!qUdclntbhqZ z;{5gf*m#ch$M_mKI2qN=cUi^*(;%pMvV1o+PBR~QkFyeB#*7w|YFsiaY?;;-=yh;= zK=6FE*OQj1_sPO>?3Vqx`Xw_{z%Sq^<7B5>(B z{Cftk1V{eqf`#q=B4_t6HN=nG~v7@(^)P(Z8#14lsPie+DygVqYJ46~FS3vQ2f zasgoyoCk-X>if?5=FZ{a;aCWIFVgixG$#*{5jwLQ;2;c+ouUb5G?82pEKeT2dr@3S9tr$4c=e_3WO^mFznr4<9BL z7w9~~uKte5m`}PG?2bn_mXt`?^%gA5U=K)P1`gGnT#AViNS{ zcf?d^M*{3=^Oy>HPMJ%vtx=s);(a*RXsj3x<3qvHM#jkb1Kifl#5d`z-!oPB4-B1r zeP!7|SOI(B;@(GB`SIS~Q{BZZ5+1Q2b)Eq{fWKvl+NIgnw|tNuUoSNw0W->k5~K)0 z;Y(%&1|5t}K_Q7R;ezaks?5^DMbq^}6R;Z*D&Q7;1~)(j0NynP4F0z#boHOq4Y3?3 z*)i#Co}L?gGxKvA8GU>EI=)L?PHrb{MHwV>#}P6H%&Oio`;&n0|K0$u#e?b(7u33Z zdPl~*F8a$T9uIB13$Ak?W6hs+3cmNBg)8M6I(?C{yqpj-nC&|-k&r5bC622<;#XSf z-Q;E}LOatE-8pMg^yn@qv|UUlrQ)dD+S=D;*qeVZ0DI%V zMH?G#>?nbbfzNmb`}Q1*k}{NSHks!W^>s28YqfEOekq8O| zhrNt!H<%&uL&H^59<%bk2mt&HC@?}onUh;;iCWBC7tDg53hLt?#}lMwt!jN8z*CHo z_ISU8DShyJ7-C+#ocJ6rP2&I2-j|0%z4w1Rr~9N$I!BgLq*DkPS(9ZdibIG-$W{@R zEg@NE=5!ZAX%Vtclr>C3vdt99ZY&||WEuNDm@#H~-e2dwpWp9#uKT&J=g;4DJ;y)J zABW8M`}us{@Aqr_+~gUm3LSGSneeFBm-D5-P;F{r(%iE3m}>kv#Y;u9%dJZe3qmR` zGNZRk(cBUA&diEoVd6ui?nonr(f@o_d`WRdIReq$qaNpy?vI$orMZf_M~T*gudzQY zisbK`CQ~t^qr|4*msjA~=&XFZP`IKJV-!>3ixP>10j0-L(x~(n9qD zor(B&v{em-K-(=ZHf`DjYQZ6nNus*P_jg0NtUa(C;az9JU|4ac9aISo{7;!r#k9d@S zU4m-~F6-XjuFY3*GAG(kLybiH`c>nJsDVs`P`3ViIrC_fi~4Q86HTH^@K0U=SfkK$ zplrMSo77Y(Op9v@00}{Xfl!Sp2@52xL5=0-POrta=rxlyZ_E#xKg(`DB%iU(=|bt` z!U;PuW?eRe`eAgSyK_Jc_V^cM)InwGzu0kDm3?BGQ&8X<7xJN~$XPjFGyff|f!lnu zV1m~8lS}`klZuP2O%$=m8#mf&U>uT6oXqiUGFSdtN^{Yfe z3{Ft&Z|Qb7l-asL6UO%$FZgXm$zw+&sdwf|H%MZX(k9Xz})uZ$7P_}12SWb(uHJ9U({lYXG1K8;tMjX zh>tt1D*P5_oob#f;4XAF2n)-PGjp3etz1+(fpQoTqU<&fajx1t2T#!5=BI6Z^wY}m zc9nsCVh=VW!g4!B|G^v32u;r<^^C|0rSFZl6n;!&=xySxr|@hgy3<9QIW}tbQ|>t@ zc1RnKNTnj%aPb;+(M_WAR{7ciOQWFN1dqV$C%S>)CrU2esd}glNI2DjhlIFlfcZRBL7rfw?}4^yoM$YUQ{(QeT$+IMJ#nM19$0LRBCGZ%X6(LmBE)U;!fd29!1lZu#{tg>6lh3xL!h zMv@XQD(GokrpEzW1v$J>_?HtkD79DTsOyG8=_%6VKztW)Mf_fuY0A%V@FFm zNy-rjYjZMqG;immeY$MAyS;c}IWa3W zQ~48^yK6Na_`>{~BYu8;luGV^4&Lt_@0pUJjEYtFEOO3Yg=itrs3e;b!O*ud)d@`s z@0*Vq6=S8Sw#aeA&!n;ZL{A+4^)QTZPz30W%5SxNWVVH5^MYo&pD19b#S_T>s}9W7 z`e=Vf@NI+uilb857dq?9;7C8v1x4y*=JR@IOUuro(fkizhMZsIX9qI+>C+Yh7iz*S z%HEaT9tjV3J-vw&yza<0W(+jPfF4{8NG{T1^icECsFgZtv5X~-G@l>oJK)!mVOpz8 zxN=@Wm_^6>n9Vi&g%C3{O)ItOgb!U6Y3wnKXRZ_T)3bZEWd>1AU6d3us-kqQRw#wL z6(k^Apu1EedSvx#b8NGV+?Yd}8748e!wgnm)H-EKqgY=%i3E1IK#zP!I9N8PoM+|t zdfF_N#s$yeacil`f=I7``q}dy|DORK#?5|&Ne*3!X+#|auzX`;ZvA{`#J8*b(_jP0iT1a<3qe47t_-3 zC;_KyDl+qp6(5cljmWgGU;5oP|NDEu%^~1O zL4-djY$qC5q(DefV)_OKunRvv z132h|_NO((On2oOXA(y6#Oko^3_R)9x>g~q@K0<|+bvZ6dXhH~^$5+u_ws^{$;CQm zbO;Mi_MSe5j+ayvCSVJt$)kAtmBj?#<{2A=tJYqsV>0iGk(bj^uxFEXilD@6fE!R09VHE;BATK93#vFu|33bePz`GXU=86M5B~wiO#Jhs{8cRIP zGp@M}M7R8O|*QGQw;_l-!;A!*RI)l7cjG=nI${ThCS}0V4qXa99Uwx$@sdRT^o!o6uc9 zKymo)PzNieOK^WmKX0LR6R2?2o=Zlg>6@foF{Vd#tC0hG#%SY}RyswEkQ!Ike@w&u zyHfdQ^NB=_{#$ygLT5o#kAR9jdQicj>4Hb{1kFY4R$#h<2oFUPa~O8!<)46#yZqTu z+Cp5OxP|;x=Bp%jxF8F2skdTuDyF;1+X!IJEeY^^!L3rLRT1_XX_kdf_f){V*rHPa zc{PVo0rdcY?=wYCY4FmIk$y!MFYdO`K|TfTdVOUL^%!xAaAf*KpOx(Hz@SY3XkvD@ z4KVZ}86lhZmbnJ>cXSBB2Uj==wNXL4(7zm5T@iBW*7g7sQ`1yp0F2>I(x1m{=ubF$O6gY4Mo>631^HfMhKHUO1Oi^}-rPP_Afy zXZGaM-8;q%5V|nSiu5-g5s)bF&V%dJ zW1vj!SprD~TAC%Wc>p7bII2N~G0@d@F`odoq&~+FoV>TVBGSzpcYD;q*EY&^Rz~vt z7L-+6IqM9^iJdGY@~dcXv->NIqod^iesz~u1pKJhyt{q=F7isL1G^}r;$Zd``2Cn(2S z4otHk;d`*Wr}@8?_s~{QLx71=XZ?H|ObX?^{c!w!ghRmklXQg8kQ>O2{tzt5zb-&- znD+t?SZ=&Pc4&mJ$Cg*GYlg034A0gBbpb9_7fOl_Yze8vJ1xhjbCIaSr^xiFV2!Nd z%h_KF%?j&02e0Qf@8%X^G0DP?3y zz01)zQGTWn8&Mf8QyV#AS)a4LSzE0~JzjF>eZX>BPr6dgGCRQJ;^F4D9!_9}BdG__ zRxp<{(STwk8>K^GgjVz2r=EXogATeEjy{A4uf^EWBal z>uM)(eB&)!1&Xvm4(y6_x1z|WcTdd8@3+RDQ7zoETwOcJSZHmrSSFg$ljLZ+9qayJ zgmd*C`{|WuU!q)*T^alEIl5w0hwQgB9=gPNEur82776z50hz`7r_C7&T!LUUVc0HfWWr0=;`ArGPx!{1k z*@ntxFU%KLk~3Q+VvKZN8&neu$L#uVHB)j~F*(kihP=ATj!ZZx0)v7808-YvvI?LR z-=D|5Vex%Ag?&G%rh_h0k~AL&Gs#_;Un1TD9AwR-2<&?M3rTD^T6tqD@H zhYtX4DDapl(fiS(W;G_!Dk;fb9Q9~tk>JH0-Tk-L1Re{(auq##31efUdFI}{P zh0D@)rp_^)m$*G9coY5bH!g6pDTu*)`0{z?bc>y$2BT@04t;HM|Iq8x!%VzZtX6X5 zHuGbSk0R2wLV0~bs35ZeuWvIZv*8iu*eUh`{hZS?`IggrR-tm7A7>R^N{!-(nkt|}&!wN93D z$G7Cd(xyisA8gkKylY0#)j4*o4NT2A4hz;cSj4hbv61WTXN<-1xksLv9u{RoyyJdkzsrII z@Z`Bezl8&P7Zvq*=dvJ7JaC8s1fr&@3Ii#mEP|=8hx1It;Al8rA;~EDdg%@Cn&FxL zxMl&GDW`rpt*yq(A;CU&;|SO-4-Z;*$=q{30zc_Cb%eZQfDXxsAXJ{2oQ%&S%r-ue zEFTWnx4iw{pUpHd(;ipr<5IK3TiTq}i-$AgkjZZQV|dsF=B)KW-ugsc9I5vfBX#A8 zRS^b)&w88yeks;o`xc7tiQzQe*TefyXzYBTQO-@pE-a>M<{uwa_hvH(uesr*&1p30Kkn;&;~dQfP$Az}dy5L%U(!}@xTdZO5ISj z*#k|-eD1d=q}4b&V>}AWoQT1b4jZN5T^S?zTL(3iV{Bg7v$4%cv-h(zOnG0vAfirw zq5})ewXfqX3F7MR9~ZxhgK)3m?J4SerUy*$lAG7A&W)N$Y`g!tcHNX?KOTTO&irzo zQrQu9k;hOAf5aoeA8Y-TUAqHzE$Ky{)(80=@8hR5RPposRJ?8kxV?DkFu&$I>~@q& zR+T#=gXihzqx@GZEPUC#eW1{u!n{E4406wsL zmMw5UDqF~yOHR*jSPxUW@oy~Pn=|8k0s455#+!Wppv~ki@y&1X*ZhWm)~EwCvv|N1=^;maS&tU^zvD*5?SWKgO{ts1Z&rh8s@cMo7B zFdkVqan;YWb{Aup^*s;x5A!~ltUk(=1F^wvn~jPDpztmsqWi~U?e7EhPskWL_uxF5 z_l{G7Kr!C-Ig3jPZZ3kwq)auNX9*Mgbhu$2nY;|0io#*ni-+G@Y%CZVWXGwx#s+)Q zpO`)|N~a{VzwY4F+6zgtEbaXfaHzHa!uNK$USy`lr06V`RoB!hJO_d~W zhP)dw$o=j(s8q49T^;2TbB(Ns#L5{~S)wAbbgW5>NC`X@3p6|kW&x-Pz8Haa5da&oVXs|Njz5>#5&or6a0QaRv7^tB zAc7VhZwNI6wf7S^w1#Vdn0)vIrXpmX(e}J~a|4{*`}4$Kv4en^K-&G5(*G;X%I zTX%eR0e-n%(`53;JNHdYmQG{)&`O4mI+DgorD9DONu-ywh&^H)wDz-)l3 zMv5Gjlg!D_=hhS0^?)G3OIT1&Y*uurxVyGQgJOdGw$liKHyl>eTt~>?90Oa7L zt|WpM2TnHGClJD!7Vq~Oaz-nMBjg7cLN&nXBI@$sl&x)FH$+z6l0EkjKAeaGvLJRY zt+7FGC#Kr;(#Zv^4(f@J0S1guVp3A@OhIjf3^;Dfa&qK$0J(^g1`h(*n-JAArQBN^ z{uVa!$@YO)Xz7)m()g|^*Q|J{KSRF+fX(GLXNN1G!&l%Br?u{>x(@FP=AP4A1%y)W z4u^yB|8VqwD*)a~L6o2X(bt-w=~2jxJGF4p+AT4g=8(2iak4HN}i)3P zmwy?69^OBX78e(XFZE}>O<{&MlJHyiYCIVtCHzL~p1GRE2V}THf6#B0lB|TNPhsP$!lG8dAgy>D z@?td_&ZD6V5WX}Xk1RFtxl07i6fCXINx8*Z`Chzuv4wM_P)J%v2I=)ezJk5u-BUn5 zqc0ewhy{!?M>V7)oFG|6z%sGqn_UUe@8E^_AqjdB4y+bn8Mhu@e(T7&39drupYB5M z*VfSiGIwiIUD_Uy)n7?!q>XXE^lTkEv9XE}9Av;MN$w8YAqY27%J_bRoe~;qrG6H5 zu~vrUZN)G+kzty&HpVR&Kme%5oD!TLHJ{7nT9KAw5Ew>6LJ=BXm6ozPBw+y1L4Y}z z8bLa}bU_z&=$x$JO*glVk8+mpmmlS*?wDy-d5hPzvASqI0pj(CE+4e;1#43a_W>h! zUcGkp5=>S4DjHQ`JIa>gewziq4s&FlP7SnDRP=#Fu-|U5n;W>AoN%dVX#iu~woSb^ppllHCnQ%(ZLRK;0f( zxIb+1zMwAH05w82VUxkXKV9noar}1att|Eg<<{q0M*m(dO`@sI9v_|TbEN$SUuWue zdb8$Vi)I!KFUXkUv$S!4G`iugu2G=&G@#;~V?A@qmNU+j1Q*SAo9&#HKU;&=;tVm0 zaI*1+A!JI!p7F}DWdFgnIQ&{1F=${KP(QHYA=zBUiluMB8>v~S77TYA05<_^bB)V@ z^8-kcjX@W|VMyx@t(z{?OkR5_s^-c0mn37cQnhe-{38B`l_1&eI@=etQ`OPy8W$ao z)&4S{1hAFA_S$H4oI~J^hbdCNh|vx*muW#mfJH!a-ca5Y_B4xv$I#0zlV9hn)9V{CHr-yKK_coYaVS|IjyRZXRr4_K{5Ek;+ zB)2hDZ>L1ceK*-n7^trV4)E7z%e&$h0)PnaI%yDDU6b|nk)qhiMmREn-y2lgf_DO4 zC=${iDJ2UH7)l*B`u1}VQ_N-uv~+cGbL%j8E<;UGhwKR`cQt3G61Qvm)MS^silkjl zeM!(fY-ySf=R)qo08dVmuuF!EJ{-u$VB&c9^o`@Qft88Zg^Bc}@*8Gbpv18z08ix2 zI>{S{Ea1(L>`7Pjbz)Xn#$;`!`V?ShfUR`cM;PF7>_AK=O|QPv`qF`Yl1G(D_^x5T z@2#<#dsc5n`dAqty+iZv>VJYC5e}zeBY3`_zsFI@?1(P?8-af~fvov(oLxJ(Le81; zH(xFI4(TUwjKh9@dWryHMqmI_Ol)4Im66ohAu?wr=b8yS1&X9?N(xvJ;!}==q~Z6@ zSFb4eHA%~NS=n=&zF!!LMiOnS+!8%YTbn@0a6wK4qypxMCXYfQ<#SlsPU4*L#{gfj2w?0MQNBn$Cqj=oX=`iI) z<<4%a*ffhXXKKJx1R8MO37P1vVXXCa83a&+pe$JRsaSJmR$+%E&TKkNqycff^KtM0 zHbj1ZknirPeh}pRR?|(AwkI74x_BL5trfQPx@O&_0{7MC?UC}tK+~uXA)mota52~V zSZyZzLf<$ygHZx}UOw|#r+-B*m*#3R(a7MogbSYQ{v=yg;nLTagCF~JZKJv z^jU!VTY~A#FB)@GTth>GsHMH~ODInM$w9+QGgEz+1}ig`h9_)=aQhr5g1%P0M_&2zY7E($^b1#E$c*zgj4IsVB}Oh^>4x+8R~@te zq#MrMcM`?T6mkkXOVPUG+xz|b;iFgVDpSYO1c~co*ugdxigrhr`u>w%pp}k|jopsb z_cvppBst+QJD8Pd5X(3w%~YUIGkr4A3r$+|wSuG38hM`7Z?0<2C~-W3GW;S!k|i^U!lp>XGemU5iw@XsE`gIN24$8A276BBnq0W;2& zsA9(9CgTR#V;a2Sb=(k`h*+6i@)U)U6c7^X+wpuRA}afXe;%*dWW4_ydYZt}UDuGo z$?V9_4_X5SgxRt-)Nv4mH62~xt9Z9m^O?9BW_owWgpQ_W z50FOS&wc9jU%fy0G3NL8S>_X_-zsXUejo-Lvb$+JAQ0JZ@;amqbUXNLh6=|? zraD!rRK4?EX<*+z>Mh{7-8mJl3~0#bD(EfHyXwXmg5`9i*pIlkjrz6J6|= z{Z){_4srdus+i*t04heG;4V;nebnOcgTfuu&>_Bw=MEu_(?^)DnTm#oew*&Fd#(pl z65k*Cr!cr{9s7i$*i)Yi2eTn7Tky1q9k?1pOmpqxqgWaI({3fsLh%z-`FZ^X^P{At zyKoH%Qoko2Q5@d9e-FYK;KrbRgO?;5V+77%^5|hv{P1#ERa+?Lf*0wn9onkPk&DQ= zG*@~eUD7}xHc&J%hUk7q1_U2yR2?CI7#o32O~(mBYM0%b?V3at)p}$6k#UJlP`?)Y zz>yY@U7ITyz9pvW64x~M$k*Zj0J@=I^6f%!3+jHq#OW#2Tb=pTRL*$7ZWpN35ATEE zsj?Ca(mf!T#&!IaS7unPV_vJDx9`!?Op!Ge{RGlMi27pp3-s&#vtp@19s;N!84X$z ziJ5ZOoa2?MeprrW`8rw51f^`x@_CDHec`D){RQw?&@Rn;jRr>ToY|Qfd~M<$C>?JP z&P%avU=S+d>VRun;Zo5=u4cBE;%JjF>SGATMoLb^RKYvt8})XWfN=khFZ2%<6!6ahZ8M=TM#Yv zk~z`oi>x99G$4`z^}%0)tSAvQ!)-SEU4~^J2^hda_TXVs#7F!>`kp z8w>K;)&qtI_gKDh6SwfOY;1K9FEnl_m*Q}1b(Pz7p=$%#)<-9}No6QmDdpwMyTD+P zSLUVu(YoNOt?_5*ld}KNPn;nsFVpM?7%{coU05?{{}+Yzw7cnt0+1=Z&>}$AO_s_pXc96;THYZy_UtD5ODGv-eZQ&_``?2$R#`qc9 zW9o&NB+}_4a#$z!Cu$Ox$uy++h0i}W3U4*5_H$u4psi$)Te93PDfS8Ggzi-Yx(G(tTh^zxZ|a86o?MBk-Bu9=rgu#rzVmomOKAUMlz z80Y;6Pv@yQ7Jod*&PLx^-o!g!c?UZ?R)*2@%Aa1SOG#a9S!#N0X;qOr>4A*Ak)cQ{ zXBm+OccRG?E0%JvJ(2nu(FjARp?Q&WrM!_XI#!1bo^LI6vFF=I@fWIh99i%d+lOIW^ z2~YZEE`_bj^b)W4|N8Uwk98alAz*xoP~I92&GBjGvXZrP-64jCL!$OQ>>%WRAz z_10h_sX7s4dC0AAnbjS}Pq@{e2uYe(c;Dwbg~h|Rb7=ef^d4$!b{wQsuoBeXmN8aB z+5mI|SO*3p3g^|ruTq7fr=%S3nv5`btKQJQ|zU;4QT z^hWl$HJWiOd%70rb1PS?~9tE{w+sUEDkCCj9W1{-gfVJ_jQ}XJT_5rErnzTJs7UMk7YeCD1Eo0ILMg_)TkQeh3 zPUSzA{799?spkUi8w1V@vAugE_uATCHak-zYp>J&`1@Lc-HV6$)M6cb zzt8x$(=#*d-foh}W_#ma@+G=p-z5iL?-{R~dDbxz&+mtPCJO2VJ2LF`MluWQ8)BQ5 zN=n%0{BrV@ZERw4|Invod;%fN)BTV<^*Vm#B{0Do+E~QDmzfFf*LT`SRU2Rr0Iac7 zn1!Dnr3lavAGN4aX^&+rNU%YQb)!WAJf0E~J$)*76y!|2s2GC7a-=+7#`n7>_YHN9 zv8!2uy<ot|3;d9vON9tVD(IM+71`{$zwG14 zY79}S{8=@^p|Tj0BXB}|9+m)(C7#bn18j< zG8q}&=G>|X>V6&Fp20y}R}$z$&Wq`p9-S}Jl`m?8jFqsZmxhNzw{IfMaw3U4(PD#M zEealnYQ^JGGRE0m4Qk2JGSzJ=W_B+e*kxzDf^H1RUkLYFF;|iDgfx~=bej!T&#(yO z7BoApb(gFHBqM`*M->h5Bw}d*aJZsXIdZ>unu0b#ibLd+)taPjOqXrc3BmYl&sa zy$==9yrlH_bi|4!+Kb-vm66O|4qc^dNBQ`k-I8o&=Y2+(fZDwcpw;*90e+4xxqJem zD;Qe1YdK8!gqh~#o#ee8&5_C-LGOwA-#^L@B$>LHnI*&JL7k7O<;N=(;tkJGqgqt~ zNG%9nTY`)&2m*tk(9N5x&=wF^8#f=7(WtH7g#e6~mX@-U znn784D|?sD`uaK{a4H)x<`4!&oAYh7jp{~zT@hv>ZSQ9@B4G8+&OR}JzS&Plce%4# z?}>0^M+x2PkbIo;C4xlXK-@el`x<#4r~JX--YPek^a0`r^1g9i!M;rOK?}Uj1(Q&@ zz^QN9Ahx=79Rgpa5>rZVAcEK0!ys|bqhMoc{mL-br~{F_A9B9e7ZG}x2KmL z|2TBpQ8myS`Pg}wmmP^W6gFNP>SqqerrYL!WH%+&BIp!|t8NtowcDykX45vURizgi zY!A=I+i((VmVG(ydE}YQOZY36H3>bhy{@wcWnd(!m@PRgT?RqfAZVD>U%6Kx!yTO_ z*k~{_CV&N@>aDM0({HqmPCN1%u8(mUfQ~1{|5@!=1Mnpna_$0%y>NSe5H2b;Ig;~` z^tOtZfz-@kBtK_pu>gPEuMTE&+_ z=6v%CA+|Mb`h&-&@D3)F9zl}lPH1r9Mv~@44M-dxseNK#ek5MoLDpd()+*02?i(^s z1{lK*|3&y}4~Xyk(b#Pv&44FC?2JGvo%skOr$*nW#t6yQ{xWs5eWJZRUKJVT&v^T(? zedmi8EsaU@+zWV-uPe>zq_(UVbtMjrZ^k!E2>#rbWT1<}T$)Hbfa+c(F)HVWBT<7T zUV(kAr&!eTLkgsr!3A#sckO!EF_~{Q`yIn=T=Ud~v>>J-w3niSUzKy?Dz$|C1Xxu?cQ7qgz;b)HBSpYl&)oPY)kd?FbDK-V;mF~5LN5a7y= zwO}qp6Vhg5K6sGJuv6Az-zXs27{fys zi4K)lYa#~sNYT%z*d9`YJ0&{01qXa{*H1$3L?`zdt)~k{&t$)l(?@t zc3!~TLrwV?t!8*uhm?^>PG(u!KwHW0`hDlkUuRicS!pC9*Y`0M;jpmI0@B;;D4rK) zN^+H@q5%Nxmcgy3bl`jK3Y4RpcAoPSI9C)GL78hRRAYpeeGa`E>(dAo_Ae};;N>}K zlyC9I0XxZg0ofr$NGE=Zk}+~3-yQWBN+#7u1s_2hpl9p-%*Mj1UrF~py?*5J?$A4S z+#LV@b)U8pFF3!TX@#3tot>RC(j9<QDGq{Nh>De*w2nB?2ClBGG<#=5a8t0ZBh=?20QDVELGz6G8Z3dXnvrpR5;tGhce16K7}P*NO4I zU7>nOR#-kd#sN@<_lusQ63vUMl1e zl<@Gb4mEbTX)92EsW=J5&J{2^0a=3x0eR~R%moF{>ZIih&1p9CU_RKT zgQ)Nz2&Sh8tZ`JBQTqFn>YX7SzpT8xudfeoQ&y!fQ$Qk_9N;M(&p{c_Gh4F>sMv_i z&{ZC#pbzC%F$80PTgOUeX?|@ODbRVM;Ho$LmCo1BU^LD5mA_%-SgH2^#^KMsR4f-9 zE!AymY8vXLrP>2UKIFPMss7%*d+?$kKYH}S)d5;_Agwpva_q~2(?9W%I*>p$sYI}* z`VSGP0O=si1MrCy`Q2ZU>vSq~7AL;kqs}v9vWLHfwB(s~9j(tluI|0CH06PQzg#I5 zdX{%A)eq_Nq0f!d1a-8{me+0CxBl&Wa#aU#;U!*s6SSux5GjJh+U54uH^MW4r&a_l z!}h$4imavQjy9ZHL`yN3>iohEY$&<|t%=jYwe37sbR%yaUxAAyrz6+K9BOf2)wV2v zYo~hMh{-Y=A+{+X)Po2hfA3!=AelUSq0r0O`4C*FG7MMnI$BvVj?VqAY9V$)ckyv_ z>F?ER_nZ&f#AnqXDvYVmDHcwQb-kDw9Y3*lQ9RJX(jG^@oKbnByDI4j#|ZP>A?XOJie7_Xq*{r&y_@8|I27eA)q zBA0*Z*JHnNH8w`HxBqg3?_N=fby(ladjsOmCL}HszINGmxgp>A3ZVb{`}^O|;qP_u z_dfU^a}MnKiSMKxOg->*(*5u6e?N!6*TLWW;Qz;Sz!;V1#K-q@G@=Xmdk_4*2mYt* r0S*sp5|2j+e(DSVu^CyOoA@xFr=}N=paPL{^~V`g{ruCH|M|ZFCz?La literal 45469 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 u{lEJ$A=ktRg~VH>s_;LyBh&ge5tk2wzKcg?4 { +test.describe("Glyph rendering — S (quadratic curves)", () => { test.beforeEach(async ({ page }) => { await navigateToEditor(page, GLYPH_S); }); diff --git a/apps/desktop/e2e/home.spec.ts b/apps/desktop/e2e/home.spec.ts index f643e47e..0a20aa9a 100644 --- a/apps/desktop/e2e/home.spec.ts +++ b/apps/desktop/e2e/home.spec.ts @@ -6,6 +6,26 @@ test.describe("Home view", () => { await expect(page).toHaveScreenshot("home-glyph-grid.png"); }); + test("glyph canvas contributes rendered outlines", async ({ page }) => { + const scrollViewport = page.getByLabel("Glyph catalog"); + const catalogSurface = scrollViewport.locator(".."); + const glyphCanvas = catalogSurface.locator("canvas").first(); + await expect(glyphCanvas).toBeVisible({ timeout: 30_000 }); + + const renderedFrame = await catalogSurface.screenshot(); + const visibility = await glyphCanvas.evaluate((canvas) => { + const previous = canvas.style.visibility; + canvas.style.visibility = "hidden"; + return previous; + }); + const frameWithoutGlyphs = await catalogSurface.screenshot(); + await glyphCanvas.evaluate((canvas, previous) => { + canvas.style.visibility = previous; + }, visibility); + + expect(renderedFrame.equals(frameWithoutGlyphs)).toBe(false); + }); + test("keeps the resident grid when returning from the editor", async ({ page }) => { const scrollViewport = page.getByLabel("Glyph catalog"); const glyphCanvas = scrollViewport.locator("..").locator("canvas").first(); From d146e53ead703277a4d5aa6141e306c08a80f658 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 1 Aug 2026 07:53:13 -0400 Subject: [PATCH 28/29] Stabilize visual E2E snapshots --- .github/workflows/ci.yml | 12 +-- .github/workflows/perf.yml | 5 +- apps/desktop/e2e/README.md | 70 ++++++++++++++++++ .../editor.spec.ts/bg-canvas-A.png | Bin 18430 -> 6658 bytes .../editor.spec.ts/editor-canvas-A.png | Bin 18430 -> 6658 bytes .../editor.spec.ts/editor-glyph-A.png | Bin 25703 -> 23017 bytes .../editor.spec.ts/handles-canvas-A.png | Bin 18430 -> 6658 bytes .../editor.spec.ts/scene-canvas-A.png | Bin 18430 -> 6658 bytes .../glyph-rendering.spec.ts/bg-S-guides.png | Bin 42126 -> 14085 bytes .../canvas-B-composited.png | Bin 38059 -> 13251 bytes .../canvas-I-composited.png | Bin 13875 -> 4440 bytes .../canvas-Q-composited.png | Bin 42295 -> 15290 bytes .../canvas-S-all-selected.png | Bin 39422 -> 14676 bytes .../canvas-S-composited.png | Bin 42126 -> 14085 bytes .../glyph-rendering.spec.ts/editor-S-full.png | Bin 33666 -> 29927 bytes .../handles-S-all-selected.png | Bin 39422 -> 14676 bytes .../handles-S-idle.png | Bin 42126 -> 14085 bytes .../pen-cubic-curve.png | Bin 19348 -> 6804 bytes .../pen-mixed-segments.png | Bin 23759 -> 8215 bytes .../pen-s-curve-handles.png | Bin 14288 -> 4614 bytes .../pen-single-point.png | Bin 14366 -> 4648 bytes .../pen-straight-segment.png | Bin 16567 -> 5769 bytes .../scene-S-filled.png | Bin 42126 -> 14085 bytes .../home.spec.ts/home-glyph-grid.png | Bin 55401 -> 43788 bytes .../landing.spec.ts/landing-default.png | Bin 11455 -> 9845 bytes .../theme.spec.ts/theme-light-home.png | Bin 55401 -> 43788 bytes .../tools.spec.ts/tool-hand.png | Bin 23446 -> 20706 bytes .../tools.spec.ts/tool-pen.png | Bin 25978 -> 23013 bytes .../tools.spec.ts/tool-select.png | Bin 25680 -> 23006 bytes .../tools.spec.ts/tool-shape.png | Bin 25597 -> 22952 bytes .../tools.spec.ts/tool-text.png | Bin 25621 -> 22975 bytes apps/desktop/e2e/fixtures/electronApp.ts | 29 ++++---- apps/desktop/e2e/glyph-rendering.spec.ts | 9 ++- apps/desktop/package.json | 6 +- .../renderer/src/lib/graphics/docs/DOCS.md | 2 +- docs/architecture/index.md | 1 + package.json | 7 +- 37 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/e2e/README.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab081562..cde170a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -370,16 +370,8 @@ jobs: if: steps.native-cache.outputs.cache-hit != 'true' run: pnpm build:native - - name: Build E2E app - run: pnpm --filter @shift/desktop build:e2e - - - name: Run visual E2E tests - if: matrix.project == 'visual' - run: pnpm --filter @shift/desktop exec playwright test --project=visual - - - name: Run GPU E2E tests - if: matrix.project == 'gpu' - run: pnpm --filter @shift/desktop exec playwright test --project=gpu + - name: Run E2E tests + run: pnpm test:e2e:${{ matrix.project }} - name: Upload Playwright failures if: failure() diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 526a58c6..29074411 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -204,11 +204,8 @@ jobs: path: crates/shift-bridge/*.node key: ${{ steps.native-cache.outputs.cache-primary-key }} - - name: Build E2E app - run: pnpm --filter @shift/desktop build:e2e - - name: Run Playwright perf tests - run: pnpm --filter @shift/desktop exec playwright test --project=perf + run: pnpm test:e2e:perf - name: Upload Playwright failures if: failure() diff --git a/apps/desktop/e2e/README.md b/apps/desktop/e2e/README.md new file mode 100644 index 00000000..98fe5650 --- /dev/null +++ b/apps/desktop/e2e/README.md @@ -0,0 +1,70 @@ +# Desktop E2E tests + +Playwright launches the built Electron application against an isolated user-data directory and a copied workspace. The suites cover different execution environments and should not share rendering assumptions. + +## Commands + +Run commands from the repository root: + +| Command | Purpose | +| ----------------------------- | ---------------------------------------------- | +| `pnpm test:e2e` | Run required visual and GPU correctness suites | +| `pnpm test:e2e:visual` | Run deterministic visual and interaction tests | +| `pnpm test:e2e:visual:update` | Regenerate visual snapshots | +| `pnpm test:e2e:gpu` | Run hardware-GPU correctness tests | +| `pnpm test:e2e:perf` | Run Playwright performance measurements | + +Append a Playwright file filter for a focused run: + +```sh +pnpm test:e2e:visual e2e/home.spec.ts +pnpm test:e2e:gpu e2e/gpu.spec.ts +``` + +Build the native bridge first with `pnpm build:native` when its binary is absent or stale. Each E2E command builds the Electron main, workspace, preload, and renderer bundles through `e2e/build.ts`. + +## Projects and fixtures + +| Project | Fixture | Rendering | CI policy | +| -------- | ------------------------- | ------------------------------------------------- | ------------------------------------ | +| `visual` | `fixtures/electronApp.ts` | Software rendering, DPR 1, `1200×600` page window | Required on pull requests and `main` | +| `gpu` | `fixtures/perfApp.ts` | Hardware GPU and host device scale | Required on pull requests and `main` | +| `perf` | `fixtures/perfApp.ts` | Hardware GPU and host device scale | Nightly and manual only | + +Visual tests default to MutatorSans. The GPU and performance fixture also defaults to MutatorSans, but accepts a real font or designspace through `SHIFT_E2E_FONT_PATH`: + +```sh +SHIFT_E2E_FONT_PATH=/path/to/font.ttf pnpm test:e2e:gpu e2e/gpu.spec.ts +``` + +Fixtures copy source files into a temporary workspace. Tests must not depend on a developer's existing Shift workspace or user-data directory. + +## Visual snapshots + +The visual fixture forces a fixed device scale and sizes the `BrowserWindow` that owns the Playwright page. This prevents snapshots from inheriting the host display's scale or available work area. + +After an intentional visual change: + +1. Run `pnpm test:e2e:visual:update`. +2. Review every changed image under `e2e/__screenshots__/`. +3. Run `pnpm test:e2e:visual` without update mode. + +A snapshot match alone does not prove GPU content exists. Rendering tests that can pass with a blank canvas must also compare frames with and without the relevant canvas or assert equivalent semantic output. + +## Interaction rules + +- Use locator-relative positions for canvas clicks. +- For raw mouse drags, derive page coordinates from the target canvas's `boundingBox()`. +- Keep interactions inside measured bounds; do not assume the host desktop is wider than the fixture window. +- Wait for a route, visible surface, animation frame, or domain state instead of assuming startup completed after a fixed delay. +- Do not force software rendering or a fixed DPR in GPU and performance tests. + +## Failures and artifacts + +Local failures are written to `apps/desktop/e2e/test-results/`. CI uploads the same directory as a Playwright artifact, including screenshots, diffs, traces, and error context. Open a trace with: + +```sh +pnpm --filter @shift/desktop exec playwright show-trace apps/desktop/e2e/test-results//trace.zip +``` + +The project definitions and retry policy live in `apps/desktop/playwright.config.ts`. diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/bg-canvas-A.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/bg-canvas-A.png index fc00ecbf0c2491fc25670444366ecbaf1199d810..a2b19e7efe189ff02ac50ddf335b86d58b0d438a 100644 GIT binary patch literal 6658 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 literal 18430 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 D7Rw}J diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-canvas-A.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-canvas-A.png index fc00ecbf0c2491fc25670444366ecbaf1199d810..a2b19e7efe189ff02ac50ddf335b86d58b0d438a 100644 GIT binary patch literal 6658 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 literal 18430 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 D7Rw}J diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/editor-glyph-A.png index 87e55e7ae5aea4a1daaf3f6b990dfc828fef9b72..13a5b218a4dd23d5ca43fcfe8e47312626795654 100644 GIT binary patch literal 23017 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 literal 18430 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 D7Rw}J diff --git a/apps/desktop/e2e/__screenshots__/editor.spec.ts/scene-canvas-A.png b/apps/desktop/e2e/__screenshots__/editor.spec.ts/scene-canvas-A.png index fc00ecbf0c2491fc25670444366ecbaf1199d810..a2b19e7efe189ff02ac50ddf335b86d58b0d438a 100644 GIT binary patch literal 6658 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 literal 18430 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 D7Rw}J diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/bg-S-guides.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/bg-S-guides.png index 7183b25afb8f200fb1fb7d99d10b0ce171b58d5e..516850123ee8b80afc7d701edb28406d47bf7793 100644 GIT binary patch literal 14085 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-B-composited.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-B-composited.png index e7d9dbf76a556ad8c8bc61fc973a834923189bfb..1ef63e0afbf75b5daf988a191cb8d2574557a041 100644 GIT binary patch literal 13251 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 qt}%f9GZqVmh*uaQQxBmm6`Cixn literal 38059 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 E1EF^28UO$Q diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-Q-composited.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-Q-composited.png index 05c557146fe2e3aac060400b4addaf8f8ee8e974..5a3d8d99a359ae3d07f896c3f49a2d6680e5e54f 100644 GIT binary patch literal 15290 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 literal 42295 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-all-selected.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-all-selected.png index 3907700c82e2d6cc299d9f684ef71cf1e1ec458f..91ea23f944f353c1d292d3ab299d9c7a09c587cd 100644 GIT binary patch literal 14676 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 literal 39422 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-composited.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/canvas-S-composited.png index 7183b25afb8f200fb1fb7d99d10b0ce171b58d5e..516850123ee8b80afc7d701edb28406d47bf7793 100644 GIT binary patch literal 14085 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/editor-S-full.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/editor-S-full.png index 8079c38eacc0c84ab959fa4035c6815be1baf2eb..883f42eaeee318ffdef6e26f16c23b22f5f8dc88 100644 GIT binary patch literal 29927 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 Cs_mEn literal 33666 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-all-selected.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-all-selected.png index 3907700c82e2d6cc299d9f684ef71cf1e1ec458f..91ea23f944f353c1d292d3ab299d9c7a09c587cd 100644 GIT binary patch literal 14676 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 literal 39422 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-idle.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/handles-S-idle.png index 7183b25afb8f200fb1fb7d99d10b0ce171b58d5e..516850123ee8b80afc7d701edb28406d47bf7793 100644 GIT binary patch literal 14085 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 diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-cubic-curve.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-cubic-curve.png index 0316c6de19102cc7bfbdf16d06efadd45c3fc691..d909ecf513dc7ac65c27fe9c70dcc32f5f360643 100644 GIT binary patch literal 6804 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 literal 23759 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 TDRxW+J$P`x;l7N29D@HBFJ0iV diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-s-curve-handles.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-s-curve-handles.png index c1896ee5de66edc055747d66257cb8427af05dde..8bf13e3db89edf950985534db968391d1ec6f4a9 100644 GIT binary patch literal 4614 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 WV3~4;jM5YUIO^ zh!Em#sfa9Mq7Z_O5&;1fB`kpm0RkjsAtoebdpFw7^u3vP&Ybh!nKi$E?r*#I_gy~U z^(NW7gSIcQTww`8(1Oo)Y~2e%=HPVp8w*n~^i;I)Ajo9xk$`~R5fGG8QgF_9r`eW; zS+D^AR~xTh&3saD1tEaydb zXvF%8^ZA3XUj+r_GzYI4K3((`JL+E9#Pqsy z95O1s_-HqBrz&*tL26it$75OBxx2UuVmeP&z)u+prSxC>{4SCR0)Sj8xnV0*A|Qpt$2yJ{QR)=!$Jp3U-=&$ zEEm!5QNxQfib-|{GP}Az^dx?}wJdSHv)#3CH#L2*x5VG$c29llnLQ=7!{_NQh3*vV zYaul<{PeBq{?^KlhTB+qOM$Z)P$A_1V!|*$3xA(sP$_i4^drM?&Uk5*h7k-#TQIr; zqbD?aLZc@%dP1WoGr~st=YL zV)E0){w83^SZiXuG)lt=hQG-cGBV+IvBfoZL4jX6F{3})e_u8mg1UEt1~BQEhep}T zhu@9kBqYe6zkS3H5~^0P5JrFE%Wr#5rF)L zpA*&TdW|_#*p%<=%v?gK+RRq9V<*>v4*40#u>Ao=&%JCI{4YS3jk<*KCp+O)X#K(W zLR$@lre+qSx(0mQ*XI4gQ8h!n)Wm-vI=MRa>xWu=a@%2#xSTxedQTuRrXTgaG}&P$ zmW_vs=Tq=AoyB4#T~S-Z)hTGc+8eG&_0R;_{J4mpiqIC4iVG_S65`~N(;YZ@ci^)Q zl$$+%YG1Kf*EQK2fme+rs>iP7>AGep2jxk-0P}Xh3if|plhZRp(8LKQx@ukh+%!Ej zoEpKti%T47Y(eQppGI$vSZdEEsEgxqky>PqfS?fT64YEWN_R6Cr8N_9;r1}9xHL_T z?a~YD1z^Wut)0oz3|wSP%t&eHc5BU})5z&??Z|6vdxbTOSub$yl>!+_q5wt|#KVL% z;d3*r=%Jv+S&Y*-dWvUA7$5Ll_&Efi20_)}HE=lYgl0P)LZb!Vt~ivE9_7by5OHR- z1yM1|>+9YvxYh}L_3Q>uKkeI2yHL9%T-V8%84X1@?J)a!=4Jxy-#{#lVbPf>&_?Mc?2;v;lY;lyYche7MB4li8XCK`wnQ_`P3eTx2Y89MoHE{^Zu<o$=p=gP;VYNzRybgZqYzk)L31g!&@h8h>!GDlB(AJaASS;VHfjf>x{^e>RG(Qb6P zJZtx3c&)vU<@T?j!nB@zG6UJ}h8%Hg;9B%LT}RN%~0T!ZoJ zWz^Q8h-)B%NV1n0r1vktvysSuOd%1Oj5+>mFTjWsV=V{l@UK_4$^~iIC9C;3n*>c! zPg_&=lDfWgv;OFDnZk7DWmjasWk!g^(0)vQ^9=@(F!gjEisf8eKKnkbs6bW|&qYd?bA9 za{bGhiaL){PKqM0)IovK#(2ZP3mDLltVERC)5kYs=y-u^p9G4_HZ+PB>5i(%!lkIKyV>@=0rjkv^ z_8Q1UyzsW7AJRIjAI*^3T~qxKI^uL3K~OEHq-dl-Nvre1?(2@RYy1G2Tikm%9D67- z*eCI5TDp%^B2U-cS8@44`X1EH+np5EElQ|9^BV6$PH>5OxK59~@9V9g#`{j8w-d8^ zB*J(-S25ZZ(-&JPvP6)>G0psm^R-mmn6h$*DgFF3&{HJk#E1o?Zqy73=ZV!Cg9`2xjfj}`^^}C48O8zoCR#l>*oOttHn3-v#pQM(c>s34; z!Ta$tE#U;Wm73=Y*U2(T{O}d#qSWdBfTuXI?|t2EL7k{qIo4FPJ=g%CFf*Xx7%II= zd&9bPC50oafODsA2DoxJHQ2-PgJOiYRlRh06KOqbLT14c&5tF0k)V zG@}wtWP<1pL2}>6Myx)nu(aU|KK5vE2}$lHh&FJN@pvg#g!X50$S;5voGh zykOC(4ji0Yi&;nf{xDCXjP^#_&K4gipFZkGofZVq)jPw?})vWYkaC|&K> z8tO%`#W(`G{opV(hYX_;J4R_3!Ci#i diff --git a/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-straight-segment.png b/apps/desktop/e2e/__screenshots__/glyph-rendering.spec.ts/pen-straight-segment.png index d1c9902bd2370f5277ed2e07006e132e2552ba4c..12a724c0b97b853186df798e9bc365ff64587705 100644 GIT binary patch literal 5769 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 WoIahmYiB8V0vYL>>lL1Hy!Rgy13pOr literal 16567 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 wo%%E7|G&*Zv<5nvL?Z98 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 diff --git a/apps/desktop/e2e/__screenshots__/home.spec.ts/home-glyph-grid.png b/apps/desktop/e2e/__screenshots__/home.spec.ts/home-glyph-grid.png index bfbb0cdf248afdb3374e033381a98fd7bf431ae9..a8204af1931bf9d16dcf0929b731295ee8f0869b 100644 GIT binary patch literal 43788 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 diff --git a/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png b/apps/desktop/e2e/__screenshots__/landing.spec.ts/landing-default.png index 5bdb878a6836725f656dfabb97be5cc2d88a0b9f..2bdaddbddbbbd8670064994c84706d7ffbc3a677 100644 GIT binary patch literal 9845 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 diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-hand.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-hand.png index 70fa2d17fe918dabde268f51fef34cd976da1575..1ca434d1cee6035dd4b969307be3797dacde8565 100644 GIT binary patch literal 20706 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 b&c`u3csV8O>dh(eFBo}g6{!NrJ5T=~TV^Gf literal 23446 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 diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-pen.png index 33835316279d380fa1595bb40aedae07410a6e96..8ae8dc885a9dd602df0a53f70062696ff51b674f 100644 GIT binary patch literal 23013 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 literal 25978 zcmeFZbx_r9`!BixvE!kUR1u{Sq|+co1Oy3bRHQ??!})oZeelGp9;^_)&Dxnw}`+wf-A;Fm#)pX-SnyLAlCyoVbm&u$#^QBjl{ zN^Cg9JN;3vpQe;x8iV-YZwS3Fnu#=X@>tx)at!odjvQZX zo?2TToEp4tI5|8-UAY%M5`NvhE`UB3w(Evz4<8-|<9YfYPxzz$2p%Q;aPRLAuQHGQ zDl#7+CnGB~?nsV{yU?JfqM|}ZM)vgS(}{_R6YzCe#&Wz8?Q(lYMn+m8+dCTmgr~?q zhJ@2^KZclCKN4zrzU(gAcbvK^oLea~N? zY>EuU+#s=C94s~M$u{pVkg50e_V(_}H*AR(=EwWhm`CZ!aJ%UHdL)UZSo4w;FI-+q za-E%-Sw=PUa|er*f{uvkO_jV?Wp+%xj(+w8svo*SRic)rhbvta72DQlI!G`k^^YMrf zGlF|-p}o{*O7+?qHuqQCH;FMuH(ryit)c`pzH<9`5FF}ZQkiY= z90_r}x%=ba&T(6felH$&&QP7&Tf&MPwQ+Q`Q zXsS7y+HNdV$C_4bX3?#|tFf-ul^fmEd}a^lsXy&{vu3|b*jOAA_J5Dbbyczt(b& zif6j8N5^!aXhE9ZO<6;utWApgV7CWbH4&n@Sh+PEv8Us{Gf`7hlY>2UP8>}L5EHWB z85lX-T<=g2-x{=mlv!yUbef7L!Dyrf*)R5fcKb5wJzl>clqf12{VG)1wdOm8sq44o zk*t|5v&EUb^7*{RQQ2H(hOxcHinV6h5cP;D>i)9Zg-1NE{NsteT*YnbCM?wwi`$lGd3Lt-)3>UdjBR`VAaeb=R?E9&eoG@BYAekg#a_d3HV-C-x%otDdfg^KaDTUp_GF8j7# zPL@kYbHXmuh-*8)5!!l^Y#1JC%3<5-&TtUpw%I3l-nC0bt!Dda4nYsg%(zbMb zG@MP~b22bbYg+q}OiA{{*0*d-;jn#^ICaFFC9x;l!#vF3W;Fjm<;vaPh7aPkOP!Z$ zG%eJs8p!1ai;bFDYmzvNm?J3{yyL4*$=b8buf%qnxAzzVmv5gFEtpA;yn@LTnW4Df zlwa9&7|gRM&MFuxdDOp-OLyN8ucl1K@pa9w0&cB**M21)>kyIXp=eAikDNB?C%tap z(Y+<6-M!dT;*n98RaSI)MvZN2lXD#XYC6j9?U<*u;5w7dZk1X7{rlRjXmzoJW@GH$ zoUcm?Tg1a)BLll1CwbUO-tgPX-Rw8DtXQdaP|o1JP&{p`KeXh5+Z(XN z9>DX*%ne#MvL{H;luM5`ouT71>xDu?c8rEw|IX!E4(&1;>-vkjd;L8}&(cte5*$5! z2lG(I#C<7IvW;hVbG9qve5hR2K^I}uqr|O-o>gf~XTF_MD(X$ZH+*`2xi-D@IxTD}$f0IX!g$KN?V5LWA zeN{lSv(e8N=VmvhrxF@&i$9#$hLSJN+g|&FoiOKfEmeTX>K$WaV`Zxjuhz7!;k~6} z6okL;eJlN6>3Ks($!kuu2Z(Xz2f_Qc)^^`NcRVynUC`DQ0!(o;nMjJ zk{>@fZB$#@I`g`uFvPjpEx21tKA}4~Da6tyi4ti)+nqx~l9Vxpaoi`d45qJ!neYR&icZsvw9TAT*^&W`)a+SdByBy!z` z2PyJ?*lSvq1l1=yK4wToevXJZRs7-ksZ)WvqWqlwyct!+*fPERDsyQL8={g)ZR$YW zUPQLjC1Jb^YZ0WL9V$5?7B|XfRT!wwzt48=dY40!OG@Qc*l6r+O;e=WcODax;Y{$X zKYUm>Wj5@vsMhm$ero3@DXz_f*XFlMx ztZO(pu$+xM|DGq-w!Nn_?Sbj$xV2>g!{X$jKx*j;j7CUV@i%Sv(iPT>a$`9GbpyH6 z%jw@S>fMQZm{7|+$(sH--U41e-U578rn$VKQGaT_fBo1C#c9s=A@)<&=QJm^>c`Cc zGYTFN+)0~czgk4Mw3^nApMeIeaJbXr0d?kdDNV00|6x`SPJ2`n%X8pw=09+OQLd!& z>+0bnym}kClhvQh0^H`Bs-Y-QjW`UmjAZc>NE@1&xQ@7$(<)mY^>6O|*h-VE5IYR; zWRTsXv9WP%MwYX(vvnI43Fw~hq}0{>l0tQaOgd8|qJKjpY#HJyA!-rF?E?f*&7Vq3 ziJ8^?*5Yxvme^41d){Wqu7?rBBiEE!Qju~-nn>V<#jbAC;iMdPzOH_Z`UDCi)@L|m}` zb&~b%;nbVzF6Ls@qZYO6k#{{(-(0d_h>XYv0AiKAot}|x^5Ylf`LQ5_z{9lno!j%T zoIDu|^)qfr51QW{%~Jb5lze&ES#nVOkymkda3${&eTm57{ZuDED`!Df9qq`1Mvn6N z&vkU?#P^>8TpH7~&#Bz>o?QfliSyCjAN_VtykgXw)gH=~EHuyO$LLb~!Ux;{>?+py znLIl(*2syiupFrh4i4rz9Clec=!ZbxJEa4{yw*X9jKm|tL05QZqKF=I;-$Rw-Zd=#CH#7A~fW z45>4JZ_?EBqnNYEYOGc=ukv7XUexOWWx@9^l|*4w_m zK4of`93Mr^#k*=FF%oG}HnTkzfZH3v(Y0-DVR3FpdPYY_=?|CRLK{lmiwC%Tum9cU z!Ah4c)X#)r<7`?)9dHL*^!B}vaDeDWU+sQmelWUCRN=9iA3NkcmqnDGu+kBG$Rm~v zt$`gc?nK0jY>MXF7mQRRAE(O`>wd~$HElN9C#+mjCgqASnb+`_LwVdS?40iL{J=HH zif?~8*RAVua3|)&^Av^Hsvj&Fq-11vbKO~nEit8BZP^1mGbyqD^x|TpE4qiPq2bpJ z{pm!@3JQ2e=RUoqD;uyVukn21FF5&{LML_48%1A-)cCBR&7?%P=tk#*1ZUTNuO2+)>+7hG_7nyd7EJxe{mdWi);^zjMRY;X5ma;c;k;&qvhGOm!*YrC2 z`bLOF^O~xPBwxH<%)4ry_llhJu%++d@L+E%wFur9*q=dLykxYH-Q8ma=9)NhEX2dL z9=huW_bl{xDJMoEEZOK-Sy+fMCJ$F|bpm?9e0%0f`^66ASDN%w>;~$%NFrWx-t~-1 z&c>HxCHLZKpRw!F<_Fw={riT~c5 zbpm(7BiDQ-&LEwj&Pskb>B+6@D^17W2bE&9igNqp<#V)}_FnG8j&rcG4!G|v@DG^f z8^$LKl+jbe@ z#gZq=o&kWOqaB8wAQyMLi>nF&&2>9d6A~73Zh31TuBV8Lu6!qlDHmwzg)|zprORV(Hf@O#yiP}gNq=hL zuK23f_CpFeyZezD3QsACNw4W5yTKut>cG@OYq2bqUFjF%WEiTzp$+S?@{4!VWHwa3Se5ZKSgLeI{UFqvsh7mO z*B8W0CH8THND`+)@_*Gz{w16+V9{)6mEmw|L zgJ`1v0`tHA!>vc4u2thoq0}bd3s=fm>obha8c^@4qxaYc?tPW5bfzN(^qH<%Hge7+pV7zQ-~%jf3n%4I zqn>{~3hFu(eS=k%7?;J;-b#ZO5(SaAj5k2yIgRrI#CE46OtWP)LhqD<{-BFUqd0D- z#kl3W7m3II7=5f&?RoLtwg9OBx@db2je_YZ_%$?o=dUNO?k`w+$O>DQFBApI3(Z8? zG^e-zJYc|9}DR=#UQ(-F&E|J><4jxy6CqU(Ih8VvSkMYvka>?ptRyM?vJ$0q2Re6Hs z%H=Muy|1S@ePf0|-{?$L+^CwK#HHwB-Ai9e+;oI~qj0JbTDH5HU#M&V4JYkeDj{2= zvLz_4?+eIjh3$QnN0MYi*JnTTf{ zT$U;!i+5);a-^)pc7^R`JF_&4p+DS<6+3`k^a^0kv;FsLU0zj5Y${)>LM0RX>&2<~ zvpZ|CIM;>39pU%`em=6;oX&!woM*(%SswYCHe73|1t; zR?w_B7ak^Yn8IxqcQ2BU9e>TCYAu?#`{5WszJBPM=ft*u-EmXg^nGC{Bb*RJR1JWk zqQo-*xHPo11}milVnkggYcunHu-FMRIJ@o6q!0zrJ5PUN(w6#AR8X)Utfu3oyzgh} z%5ap%VbBVJ<{>rT-VnZcRhq8G`m&T*cjxi4>&*fTe13GILhagSsHVHDPp?2&?{#Zh znvYbuReYYFA1JnLcTOUS87Mli0>S3 z{Ip1e4%9#`;YxR2+HkQ+a-rtR&lwaVQn95YK?dVFQ~WA?2;K z03s1>^vk1XOpIJV{sZW82-IGl;ZpdXwBf6QmuH1iJ#dHguFE%-EsFb1GMJ7_^Ev|R zgq>1bRcU{@|NERrn6SelJ#M>BYWSfBxqr2mDQ`} zm3+qb+Zle>)rPAKa)p*7Vnd`)2NHKj+XsIEgt}6jD9iKjHtyDS$&J=ye?`&HchGLV zO?s(pE*lbKj~~0!^v=9+SgJ`)riNo{!~-{iJKVDCR-a0awDA6^rKF@}^UNZ&e))sn zn2Pn5jR|jiz=gEKM+yMOKtOYxZvDg{f=&sm4jQdH!E8UY28*CGD3suYwvJwnZP%xPZsob}b#+vg-dI8Q<8 zefI54PSx(Ls)!*i_yXjQN{{?vI{gRCfG~nu2g*iR6K5zTB+%L(I)z-o0J~e8@e8v7 z1*)4iJX@*~7S(=f*bVXtX=Lq!TOd3N1Fq^;H;i6#TXJEL_Vikt&w7K)W(MF@Cb{$b2%Q+1azN$Ap{MmKJMnr-OU9}b#p-%~}U!?pnTkMdE_ zNl(#YQ+y%0)Qw;)RZiKg8rA0y6z7RF})73$rQmkk$-B21>|W2@$E^2#tJWy>DqOv ze25>@My)DAIzS%Y@mFI=5}Xx)gnium{^IS)pW#7^f#nA)u*$0to)YVR_9C#~RaQ|*<$!iyHW^`h z>4dIz17ll0&}hk))=#&gw4Xmf4w$3_K2KN8710+yQr>ERCdPGo%z#edf6PGS`1aS=XEdWxKFdnLf50#@}5oroh? zM=qmYSKANsTg0H?j9BbiEdtX;Z1j`Lwh~5lFUjigH3rkFbeftEA)|7zW@4ZbKx>*# z43ekfx6ry(M0N$XC9Cmg_;u9#Avawi-n5~XAz6w6V8d8|c=H)buE!E)-YixHy3R96 z2*CaR`kHGV@>OK~{cXM>+drDY%4p5ZFPWe#NzoKq&z@)CYw~2=Ab^WE0UV_VRL9K``L15Aw*c>3dGhrIBD=E!b$(SSu{D|Z^~KimnR;24)&#kV(S2GaX+dmcDW z{y4x7ik)u2S>xmPz`C2q;tq{*4%oe+)YF!*hL8iDSa9YFYyisoS64v81K?{j`rYdU zd-snM)j?Mk=mf3xfp6r}WR^}wtb}C`yCRke_S%QNC{-{n zH+T8h%X9v#!^Kq_Sz1%7n&x)|~BcSKz&<*8tm~nOD zho8Tl70v`70sck@!g>8?7#D+lti-2nwA(QXxE57>ngj6|S~80~`$HEbB`W($K!LxF(^nflZ?p z#pxv_(Pyi&vaJ?eJBHdKc%zeCWB08zCg34Qo&<+%?&l-XB_W52gDDYEhaU z^i7Z$fp(^qGjrF2Dr^fRZ^~K7e*U4dvhw}=P$S>I>NYxI@v;E!mt>k&!~9-AdKGr1 zwu2%Adhc)0E#HaITvelkXWMolVH~X7?4M%|Z~~l$Y#FP^lhCkz^SP};7=@nH2`mLd zJhAbAxY=K5s+KwrAalA;S zAhIISl%S%jDun|o?*{CSjGTOPu7?u}sOLliqzz15^bA8VTuK`T1`DXMV!d4!P)83F z{s;iG53iogg&W4~d;@87rH($Jc~E7NC=AI^z^ghdwk&C`-?AJZN19Z$UAN4d%}^** z3{L~a_rs%1!I71Nwx9PZoYvw=qr@srYdt8{E z)Ya8#5=%-1J~2+WC&^}OYiLjzazT}6ukaq#Bi?KgvfWJI0U zdm%^R?bn<5M?@AZVNq_MN`vVU87$f;%SH{nY&CFoE9ZPWPNqUJ%Oy_3yE(pRzp7yut}Y;see^VCD`;lDpa>o?8!sHu zgFV>~uI*ROQd%IS(E2AJ=!%>-4YZ>kvCZBmE9;O3B@n3~%?P^hZzIJN8dBT&1whh^ zP}8;7=b;}Ufw`T52HfOhG|z6l8b@$<`P;czF*oP-^b^(a`s}wqn!vzKw9D=f)|{wk zUr#_9)I!DCfDsHFvTB-B>5%>%X_hC0bw9YYUK=*;3Qe|0$7KP`X&(P)KbTV_2pu8V zfmlQeSdAK|M3X=Lo!~FT!qULSX597XO~{R*PkzuCffCXXbT#zjM>dTDrnDFV+653q ze5rWTVZ5ib`3_9eG`qoCCR|U8VXkys88w2@pB;dY0k^ZtX48!oSg)Y1A1{2D_g2Mc z6L-MXqVqR;*z$2Z>0mG6+0AVUhzu`1sU4u%>$EK^P)nSnL*ZgarU~bp%rN zGC>uo=bSbXwFwK%8llWIhRU`g0JYtbjfKVASIBm{)d-wCnUTHWt)pkCKAvUe;6R`o zR0paBn48*|?P8?@$o$Se#pJrd&v#ri^-$X@KxCW%+A}0GwyR#1m)L z_N4{m8o23GYPehQ+%ec%M3|ow92m8(P(&2o z0ai-iR?2G|Y>a|7PskGFyL@6*2o@^v8)C)}(W)|;!QYjhh{Z7BEAw?#k- z@aSe`Gs!X;21rf0K_>{c7U0%A=sFCKO%gjIBE|NXzeOm(la4L{rB$T&-#l*^^%(+% zu`Bclc6-$4vtb&tWa$#PGZ33r7$T^W4dp_47BICnIXHXW&={gw*h z>$*-8!7q=U8wMN`>p1E)9S(a0{WC$L0xo$7=n+5Ee+{ z{NOhyddCs)AYzRI&0bey!c+r5Ky>I-l&JO>CJDR}I5z>|g^oi97)mARKde=rU6GZx?boz^C`fw;9ffv=ej z^K$SeMv#P{5hwP3?lxsNHU@^{srsZL`9?|#2?ucI-Qcsr^SO`}V9&J1XUEq4UIv?5`kELQVsqEWpwGOm!`Di(37B+uK;c|~y_#>KY2h?)NLx+E3XT}l4quR z1g0Xf-K*~PYg`r|ns5V_RJQtqHNg$E8A&HTDrp+4?;r?Y2VIT{Hw--x`a^>hwK?g; z;G46=iaUX_96K;&AO!Ugsxl6QGr3L|1YiYz@%Ctu$OsfkDP;8oqJGYuq4zZF6t7`( zls~PolvL9O;6RBgxtFm0$3N>jy?0#u5N_({%OI`5!P2v*!Q<23-X4K91duTdAOkvS zpjr8Jyw966)T;ScWB7O!`T0veaP@t@#8SO63#cS3CIYXJd4@lY<5Ul=`c}Q23+z{Y zL@+sssu(>1HqA!)(J$*B;M;=@sv8mE!9)6&E^>`|38qD&%g^)H!KfkPpS z)pFz^N#VL~=3jFt4F3!ioxC&Dd~DG>QOXJh*~WrRuPr_)qqnI)LZ%1Xrw^?NfUDW) zuN|<|!nhI^i9l0$uRj3uu5*XP@%di-`u-y(`xZeHQiK3$>b;?GL_t*sA$u19a`YT{ zMF(9u?i3&Lfk)l4ze1?EPy}`1EB$a)i#3lz=2x?9YFq!+euluzi;Af z`FpMRNq{8F9)uqiSDt3E1=5*?@={Zo;`<>(1`3QxWY}o;0q*SQBejQo;>Xn($4f$p z&?5((vts#{$N`!_1@ZrgXWao zkD#FtguFL$JVa}Oa)}JN`SEs_KsEGO4T^=~(Fw4-?ou6+hD4yv)bAf}2-;hpAyFBD zLNM{2L;*;tXvZPUCQIXh2$w)=OIyndBj@8HdwLbM+5 z4&j@OebM0^yNd-N%8{=-2kMKl|~4B9Dopp z>Er{TFTT1nsB$b&=7K{F8(f>ipJxSfCBU*3kSTn@QaNe{UVs(%rJ;;rg-23U4h!2ZrLwx+MNl5x4Xz$gm4%$ zzFY}vT64DfK#{qKTpnz+A~JEJ?!!2=Jm+-Nfx&2K_+Aql0g$Ku;61iQ?}q>__cDN! zL)a)0kZ}>P8~rX}QRgiFI!W)3#bo>-`JTFYVb>)qZ3rfW_(2MczEaGjP4}!C5A>*I zpmelP!2Wt^yAK=-CO5)*Ny`&OiC!jLh$;e3)PcOOa8j}%0f%QG`&*#BDM(FCMeg9p z&l0OKga-@Uz{AkBsv!f6h{idd4qDFsTMLkwU^-rBDd1iEc@Nn#D(m%SGxcx8UMDdi zgww|j4p2+FBtr}|2M^z$e4t0xSL(X1NG9eL)C!bqoR(E9n+4z`QX59U|LfT8H>U+b zssG*r$o?&~U4DzeMDhSRPd}HZ4M!gc5mYV7a_6$RL@)xk! zaR|)=RnR3J9^&U>}9K88T(b~I?^cXLy#k9ZadpK18E5wJc9=e zc*FoVlCityb4mZ^&6`SY3jGDw-$)YWfhN}G|9(@xaAbdyf5jF;unO4N79_ozjuT__ z50C}wECA2SY-)i+rAQq>FYe|HoI?8bKDf0$BU2DpL{Eny^NXN3d@!&nM=SxgN>GiL zk)|g08o1mXY?WO41H`3D~=zt(8 zt4vTfo_vaowEF-^c-CuYs;n2+f!`HNd{_d_QU?~_Wn(sYJbYzL9Dz6mf#0u;5QIfv zW^W~Cdr=7EJ-A7JtW1u0vlq8z;vn#pjhT)qNU-^y99@eJsE=rr;Bb@z`%ZrkUe^Pp z7a&U@iqN)a#K$^KhC|Iw&CE3GkmCm~H4QB*gQ;2vQXBy^5Zo4+XtAJ4K)bbqm2~W!#q1u(G?IiLP+N$+WB}7Z=4ZVEko3YEB(r1~KPOib;>?t;++B{UE(ic0>7wtWWN7?Qs(a zCiuAGZANz4ER+n#3T|{Q$fGc(bxX=DGBr3FGoQ(LE4+qU;y92#^JVnO)j`L7rvT*RA2~g4&xGCK%;y*a0H3>w}RNtE9c^KN9_63-S}DnRr%j&G^2Hu&nyTz?0a$cY>&>!|j~V zbR5`b%g_OTWZ89plx%@TpCtB3t)u@fr~A>N)O$Q{zHp?BpXbMURd)3vO&VUsebt1o z6m6UeSHg?ay1##s6?wjggd^^!cb(TpnPzK`g@?$DarL)#e0(X9v<^niAk1GPJsHLW zQ^-2&gHTi9sr9D?o<9OYku5+))A0+`<^^q_$(IA|g+Y>9!+l6T==W-$biP7UYq$&XgS6)zVY*`lTgJo;HL&_5 zsYh&a%Q<$pNgM4IlERjAvdX4r?REF_t7$o(L9Eu4zI|Z=_+t|6*g2T7Cq zTBu(B$z$Fe${cGbTeTa>ep&@3Q zlNn(!s@F1Z?O87H#m;E$VHpL)Fd z#18vxwagm&!2juF*ft_k z!V5@HDDHji160WaG&@SL*!q#A3t{E}|BV?H zm64HYzelbjtML&SeQPBsJ4JBH4^E=3!!Vu`AW)Fwd>oxY!sHRL(ecJU=LMx8{qu;05xqp72D03j;q9$ofpdO<~9MJNTNByF=J3} z&VhXb>e##Q(C!ohz!bea5E6&K4`=Z_o+`*8ehPJc9L5?&LDn&DLE^b+8!YQMn)Lu= z!tpTo7kD?wJORL0y}kUn1BCgvXKAUD#wgJ+1p_?PuSOw<#R?e6{{Dn2p&L32+8$U% zzv>N;r-@j^H_lYTKjuxU?>&%TBdY@aYR0>kzS@@3n%v1=tWH1}Wlq z?n1NB{`ggKuZPqrjPX-QcoMRkMO+HBfw~T4UxTq5YX> z90bzB=+Qn9*#hp#q2SP>^kdqWdd^P;$m3ldQ{9y6Ii%~2jd|6A7f=Y~sK(11x}a2b zRl0_&2A2TrAr?Sue$H%=hKHY%L;>GtvaRKf#Gm zDsm;8qXa*+wT}lsm35*{Jf7;8IKuQBO?MEXDU=~cXV_0*`1|n4F}$YTXfO=@CGJ(MNCSKch4Yx2~Q*p z01!@on7us1Cji6}>F;2^w@6ZG^MLvWr#0q*mOCFAVroS~9=rpcz%#wXk<=5i$Z#tJ zH>>k5_7ezNi{|bOTaa~ig-K~inHc^Y7~?BI$F2u(O?CeGZgu{&Zg-sIL1*zKyGJPS zGU79S1B8J?xQa-_KYmv z8P~=Az`cVSdyLi=O?4rS9H{{CQT+j8Edr1L{;(uLK=>y1@-Th&!s_Eg(2Z*HGBpoy=EIl4^Uio7M6ab z%L09-+=nTK6;KWQhOir1mXd}|1hqs+Mu%dHys{qkx#Q?;(P^JUG|bqW`v_?!6;lu< zT2P@Xoj05T{z4_HgrUVx5XYdFqvPR-v4RG^58eO`3cX#&jZj_pQlJ-)uOl@y?+Bj2 zttS%Q2;@R?HQAx_(g;aItR8kgP>|dMednk7F4XM!$NO-S=`n;0h;H)ph4A8{AetcZ zm2j&Z>1DgfK136Uz&lJ}w3q_W1|&T7r*))5BV`UG4`kJX3;}|g!Z}JVL>0xLRqSmo z;ec#%FJ!vBLe(B7ydbg#V6Nx!jf%Hm!U4@f*#!z0Vlrp|vX`~pT7O8uF>x1|I;#*v zL!AP@^?1F2hHpR|QY8>Q7fTc5Bnznr%cMD4_#Ca!x*B%33z*?wHsk#nu^A9JL=Go7 zJ-lMjIijI5U|95^7%Ae#TsM@LXgz;DIR^%AQljnqq3Y@?DXk*#St~*3?@?v^h0ogh zqhT><{6-SwL%?B1C#+}q!FM=@Ou?hnX8$aJuyKv0Y=iDmR$+Jg)A07d{DBCj_kbo- z{hS~nk;YhXwye~RK@!b|nNLyK1+=zC24JH2E-6oO==pz)8Q?#Bs|@o6OR{kD)^_$4 z^Y>?g_%c68{uv`G+z+k8pGO#m1&!*S6eL{bw+7P>um<>tw|gK0wWfgZ{8KmzpdC4% zE4IMB1ES6cfC}K1O0y&Qqk_l{^<(I-4j{1n8S_A*k2_3}CVX2b5oWho9K`$kJ4-hnNoki!I>NXt-6w=Zf6(W);(*+0_{kSsg&_Ndg-g&Xb|J zq^#oMewYF<>Y)*YX;ql$K&j?eG5}Lh(_Se6n6}ZWAebGJs6z8Z$RLG*s!7BRvWUbG zH;~u{eM%Tn=4mez8;R*bydDq%kGCMc5g#4-Mtkdc;jqp3RSmzvZpggfa9(*EMye?n z-VK7N<6Q@i&=SBr-)m+>Y(Oo#6XVnu4M@`pq2v$CDc<$d>)T#bEb_Z+h9(UOq>f}c z5L-(YM)Vx*ae0-Tv4{SN4uH(OguATJ(z`{n;FTLSpFT}8^;9<@+;J7wU5$boU48P( z-!Tn<>@ZaErw4GuKd1pcfaHJw@W0pK|I2ak@IcBow?NJ7Iyj5R1Hs|01~mF=PAx=1kZB6tVpO z>ONU@6(80Nuz-~E=ZYAk`4*J(Q_LRs`C=7vcr(9M`w!VKh5+f-!M}E6j%dbVQrFOa zD0>g*tP_4dpb&rgs|RP8{ZheVdFCoM=JOhbzdD-r$cn_AZcUfi9r{H33_NjPX?#4#*Za5TP z6>*pi%PgcBR-#PEJ&hSZhDRBv5`OUGXGG6IZO7s<+Z z*H=d{7YIPk(TD_j=Ut&3J^jQe_^gn3SS zuv(5EI;&!W6eIeyDO@C&h(QX_qF8B)3R$qF`(9QAK{`u@K_ySZ)J;(vQPb?wWawD> z&re>vk+BNe<>~X>m(3~cE%G;m=Q8borf)S>q^v!sRTIsedhlAoUM$TN8@oC8c?*ZX zOsswFG>jTT+GpKu;?cYxW;>LYxJNgnYrdBwc5G{5B`fCj%Tpsu>+TH}L*BdOU4xB6 z4>32$A!W0qKYAeiqY?eV3~+u#5yP}UC0G2SfA<%BOx_K&w^DvoDh`C15kc`tQ~`;< zVA20`Xrm{dr>X+_{p^`)USSw{xNKv`g8VoELLRz30yID5)sF6gv9!opOTxGtFX*YMH^9#(E5%T@Br ze5qn7j=7O>5kpWwoB$65&RIa3F-AjZ?P0zGb|j}u-9bI`P4^dX zH$pv3oE;o0(C`9n$7AB;L*_X>JWStZxX@7|t;jrl)$~txxm2^qFh;Z}B;ytft7P2% zTzJU@_>aL&$jdNzUaHQ@ZhqqnWLMARyd*n7@92%*@n&VuZg-?N3A(B}jDQvp+l6{& z^OL2(gJpn0ik4A>71F52lqs*f9Nlgs`_1X|#GeHrUi{#e3^p;MWrInII5PA$6^t5qtX-`_56^56U<&Y|%=oA+6;}CW z{`p+yn_akq5M1G@>9od(lxp=|-h#8Lw-3PMLXn%-<&tGN_VZKyq8f8!4ouzxJ;jtP;jU8V|Guys1V=F%u|+2^tb>US!7?_E^PhhM zpl#{>tSi)S)8YrmBEJ+0&2=R;)-GW^o(z3}W4}P!I+AJdU@le>a`=nb{rpS*IOJ#P4A-ZL04*!Y$OQRg zyHq0!O!+@AzWxqp4I3()5+d^gyVa-?0#WrN)AI~TAEt(XX+#HByI$zwpNBpV3zFpR zDdhNUVW&k-_wPOch6nA{6P->oO1o6j9bHBnS|kQ&@%z3@FZw;owAL6-3|% zyWGb?cDYcyZ^P3%UHZt^bMj%DRQH$ktyMh;hCAqQsUl`8NYQqkECaug?-Uirh!?IB zk}{muEL;3HZQ^6tP}_O~A+-u@zQTggaR$X&-dWYk2MG{@mtd(TnAzE3@16ejn|bA# zWB=9yRF#9XUJ_yo&Z6LX0ROm9$Rol`<=b<5k}87!!0!rN(JwmY{~f7%@T#LwwAg=w z?K~@Q=byK%^fxH~WQHADx-jPyA7c;B|JPiJUpYZe)?J-tgCmN=F&vf8Ev%M z@K4QQ*kvGL8Jj+RN^4fsqysS-AJh63?Sf3@-*xY5ebLgWqj?lle+*tiR_Xrz`^7eW zYJXP;?(^rxZP7*%1rHyO-`M2b`@0F&{E*~HD9L?OznrBL0Y22kD_i<0jyh+K`@eoQ zQTBHu?>&b!LYZEZ>X2?@^S4f9CYA>AVXD31*j0g9Q{mrFj;dTTgVx0!0=vb{Q_!w* zJ^N2SvxVXjVAx^!r?1r3dpNqxpUQ4FjA}s6%MB2>q2{v(!Ef*-WbA8DiGnKfZtw)=MkA`j7*;dF z%#VNKQ3mz{Z#E0ViEXs{vp{!YfdX*`uwxD)*f0;{r(q zwro{HqKOO(PxI;S5~wL!M^9r4B;a~FKB?+UmHt%er}-1$v4|>$PMp9Pc|kr^bhn;- z*eHB(rOV+KRQODE6dzX058dhH$&;%4C@8Ww%#HkvFrKb3Xg;2EybPS_tO0Drzk=zu z!wHBoL%7YMH_O})XbgW|ju*Y0nzFN6_usgJ#quPV#vHBJRh-Gv#M5~+!*6U$n+cdQ z>CY!Y?&%NP_?nsK5vEjF!lM3sEjCE1=ZgswH34Rv9gp&Yf@G`T`|u(PR{`AGfVj(2 zTw8ujOzC4tc=cna!1(IFf*DUr&FMZ}CR+7bqj;g*ZP&*tlC9t9K(t#SBtMl;q0;=W zQt-U?=bo!Q0jw+>{>?><=4zt)g|;^;ijimx9Z?YQ)~fOlS*|mE$!@oTd4AWI0R=Jh z(O0tK?6E2_Z2ZBeSj5ZiOlmsyakOX6gfj%Fd}>wil{#5lc%l#@mnMWKtpsESg9VaV z&3w`QnKqmKZdJv#y|T4c4!@>##OG-l`(E%7Hdx=k|M1#!yWOb)+?Mjpyu*~gkcl(o z@ne+73UA$Os7ET=eANv6sQD&?gvDa^{g~IBTNENxBJ=pDFB4GHlMv35m0qI2RC9w5 zcY*#4OxNLFK0WdakRIB^R{-V2sGv45$cl1?FO{I8!FTTZ^Z@bqwaVywRRV{NYGX^&ZT^TvuVh6|0e3)6SLZ$mVIw!?^+f zFo@rRpC36E#%9{wtlT2$itv@c2oH~w)_LdHlA>AQzr$=)Sc5cX&9+_A3;T{>u~}5- ziA&q{_S)Q-oR*srG=6lIbJaWF9mDGwmU7&tHjD>9P1?-yt17A-NWWN;r1O?G%*LiT zk27k^g{^D6C65pvLK`e;JtiSyIG0*})88VBHCOdG@A30JS!hbb05f+q1}|{yCu~$t ztCFQfwf5TYi~R0_lavWv6|Nw83Q+?pQB2{!jJw-zPqpy5b+|LBws(P7fb{gcJw$w2 zJ#*ML)|)0e!avWr0y8!UwnPlmI-HtP>Dll4k{G%RKzAb-LBO-1TnT_FCXXs7x5Jo* z136q$=J5F7{pOV68_>+SB+mMvoO6ewbjn22g$2S5PCH2fSpRRVeBVap+p}@)=yLmD zh$)cN8lvSx?yyXOXbq!JJ+fWibcF3P=>^3*9CW~qY~66^kne+DG-@r2H}b7o==2+% zX8KMa-LNCE*(63SZ-F$$3B zq(X95l~nJ?fQlk-#|N)tHcchcktY%0YJooNv3DJGY(buFpD)UE$H~s6Ok(OBf;m5* zPa|*YSe@Pay|~4OHrSA{e!ICAs)YiWV;Zq#WN-5FYp0olxQ~Mk$gqmv-@ET`9truM z9LHsB(?G!3vQbAzM^n2D+j*z@I(2@5WCrr@js5UIAdG65w@UtG6pWDH8~b?(dzKA~ zxYGia>VjxVI_TNZ)W6Wp1?=5W`uPH9dF%2;5IrTt)K}4fq@(e&j^(QtuaOa-2+5Kp z{q=c?s-4!(Mqa%??lO`1$DKQOvbcHfXV`7c@QsG3HG`|>HiJ;mOJ-ei25?t#CVSC!N~yO zY-xhs0-%6-$J_+WPw~KXXgNsw5{1jW5ODDDtYS}&dyPgw&&bwHUs}83<1c@4qRf;o zvNXM_bGGx&s%+)L5VwaJ+x-BOX_go2s;b_q->2rj8e@z#8yXsWhWBJvmzq`vc0H-w zKx0d7BYJ3|f~|s_TbC8vDnc@2ec(`2bzvhXH7vI9O!P~O1adc*$``8%BpJib9J}`e zOC!lP#4u(lpG-uq%>1-l5#CqR#lgf-(90G><9<5fjlg(6KOt~>F1X|F3$x==@2PM% z2Un2nb-)p^dNgqTgwK4F=)go*ePTvlVs}XML{@&5-m|%IqgctiI?DU>v$a1f*s9~1 z;kvqdE05KYkAGau-OZYx==JlQcO0%pk~cZ*SoP4`CZv@{`6Fy<9!##n#JA0^A%86j zdrjNyU~FW_9Pb<~ZlecSV2>t~d^ynJ=`S=pnmt4>sa4f8o~E|}Agc0E{NA2CaEShiO^66?mc7&pKfIzahus}7B}8goZK;! z9&SsPGz5_`9VXP+Eodn*BW`R$+~Db<(9%RVR{jt#9&u)Y2F|x0T#HVQe<|u0=f3YP z4$)r9Bte@~MbRm-g5y9ErLAkH38H0rdATOeq(Kw_Dno0fTsJ{QRm08II7& z$0qquR6nqDxN&Ut$M9R$Q9#{ z1-dCXNns2lZv3lJuQBBl#&CaclLm*n>4}h8E@15rf4^X^)uKo8?q-gEL6A58s)b@2 z|1S)mf9K+SPS2#CunX}~svWJGeTpr~Y@*Zc*JC*bMh2U%Zq|hoQmSlQ#9r|^wLkPt zOx&QvcT?y-#)t0342a?b@aasQNQ= zA?3Eq=1Su+%}5m1wa%y=T{_yy9ev`eC&5=aSv}O=*0%dohn}da$R+#R`z19&k4F14 zPi%(568<`TOkhXh42dD*eApcWZuEbUuRf;(|LlZuppAV3KE_avd`IBFapXR`MgO_b xKab@9SkHf2T!%s}F0L{??+8W4m5A!DK+T*wPmnO2-oX`=*B+nmxVwYX{t5rPj)kf!`|?6zT+*g^Y~4F$(4K7853>bW-a43tAb;9)Xk;|IZkU9;OH4>V%}% z&12baRkcl>c|a1{7O-h?aN&p3^WZaC*14xRFHpHl6$UR2lV1uWi@ooZ;OX2yO!11F zJ}4t;c;%;Jg?W#vYH*7h%jC0Ioxc0US--cqb3O0g_lURbzpJ!kFkazis4LjG)*e>i zng7z`*BWNVWx^nFI8{+$pH5ZqWx04`^sn8GrHMSJKV5-`Dh-R&x|qj-X03+J@x_ID zsjb3~-lUMZlQn z@q)B9WQWsQ4o?t8sSHwAoSa3W&Z87=Nol#fogcdgQKc+Nm!BLs%@<$3q@hU|M zg0X*>CIu@MxN%FD&f*Y1k`NC#~-BPi7o?$Ss9<2e=I>w}WXwsqv@71gLN{>|UlW#kn@Oj?gKxX$RwsGSNDUTm(PHqnRCv#@L z+!xA4g42PCiHSXa!V!cY@6Y8+&n_++RXSK`FAtYL_5GlZ`nY0IQcqPDY`hdA;vlRr zrJPFe#8=-H!!wbqo}|utYOK&SSmlqkuHNCz;|QjCt$JE2l1e13huOSithuI2yy%3lV%uSICAB0D>K zdiX_d{kP(tH$Sz&n$dc$hYPx{O}86+ACz#N!o%Y=t~HD=H2-^6O`}*gn z2~Y&r7siHv@CP_4_)-pF=GBvv*acpuogm|@B=qx7FN=pS=vVR+`^_cBF)=a8(Omw|AU`mw1f~E9|g1sGmwAE@g)<&x<&#md?8-by7#*syEwSz{z!8uE)zz zjzwpvz7zM{rM~GPFMHErmM*kC?)u)|Ql0m~hOVCb{`N|rk-PQQ;xJlQ;4)FV^{Uo!rxRAN0;y1VgbX{b!Cw*Om# z7!^6WcG;6*HSrzO!~GqJ{bk>s(ujje^V!3LJ?DkNjs3NbHWm^(@4p9Ic?M;QPoj*^ z4VKvypimX_{+Eo{Vilw1f+?}9t&83*&Og;84g?r6S4Mq3WUTuO>-Qzom{*{_?IgWjNc8SMO9hGF_*fw%-QQV7rO+J!Tx4h)_bqv~?5=I=Vf z^S}T6d0eaW+mGW9hi-oEmGF0>u0`{r!omX%4X=ZOf^bltIgylSO1_Zx5miJH)zs8X zE&ohciGTk5`S0GGj771=SofBzpLlQVP+rHJ)&9oB^s^?c$m?J?_M52NdM3C3Ed%pj zRcm3DdQC4*b(A{FjQe~gjqup!7R?M*pXtDzN-K0E9?@CKq4GSrb()S$2}2T|HoGD@ zf0w^b_+G^QA|)d?vfXSUVr5k6(hP*ZnX>B zPdC2^rV=u$^{DVLQ{)2=F#Gi4EIDcx6NT3QMz>B-t>N_LYu%yT{TMCN;GQTN{EshGWea@*ywy#H5O;#(U-ZI0WA&Cf+K zQhJz{$4ve^clUm>t{cSoDGinu(W#lK>*$RNcAc+fnr|~^@G6gX5x$LWrqv6};A6|t zN~RlqwR6bL@JyvPK1U~yqmGSB74z_UWMpJ1-N4dZM&gr!qA4(L6bskFD;)-^UDw{5d{NGkJy1F@SZY0)uIu1uZ(7vay5%VKwG5SHRdC+T%J7%n zq4MriiYV*Ex4;(FmAHSCJ8w zEX53#JZ}5d6ilUwMt8lnU>}uwyJAk*>VqPnjrU@A7PF)KB#;@d%~#)5kxKL3oln`9 zQjeLG>z#<()y)E;?j%{AmQz)UB9>ks`P%E1ICF{)^wSxOLOLJ!jE;^r#Zoj?5aD4{ z-aPc9x^_Y`{cDcDaEIR@#(kA7wzH*WZYI%N++%wgJh44Uf6(Hs@L!g$+QBobD0TII zht|xYQTHOTdj8+UW*7{l=G6u46^85Ami$-<2j4|?6||0Xa-e*86v7Li z=slKuSi)xR84|4g<=W%k?2YYTp#{cuMtes5DxK1)t=ID6Gk0EHJl9QxgQE6R8NH@| zDRy3^v$FZleY593ZZ`!gvzlTsA`1OeETj~RI<@S=8h9v&S-+{$6#X+%t7A{y9cWcZ zHyIchUYxsPX`z2E$bFD7_M4PSolxLQ)R=mC2qDU6pI*TR?L7MHv57Kc#L1HLY=mf< zx2B~WFTb8lo|80A6E1YE+b)y+O@5^PPDT8&^vN2R;8D(A_dbKrO+nYl+B96bc zO6SFxCm@~t$iHh4nE9iu#${y!vV?xARTfIJAnh`k1R2hPm#*88g+(V1xwHY#Ev{EW z^rw8JTYgY=vpUSt*Yh5f6SZ>u8ET4lk$!G`PSvgdf;d1nPcJH34agwf z;2b3pu50DvJ|3mDOWgYFTch4>($dlmNcLTy)mW3dW;ZF{twNaB56+KA>v2!K$f7%4 z@!^Q;%oVc__A}qw6T}QoNYF%>21k(|*)*>u#ObL7afa#Q6Br`TlE< z9z9xtAsRqFA{$45C=I!;zKVDs6Btwx=r`Pfg_WE)NS%i0plE&o+6fUt} zl{i?B-1wljVAt;Nv}Ul7lolMha9gQ*a8zUOYK7k^$$CtQH!btwKm>}PX{96j)EoK$ zZ`Wwl+G^8!3Z&P;{7I_Y7r*jqI-I%!_@xWmmaU!R;psV5-;lJOB>CCjHIQ%)lkO+*#$kHTMagTE4C*M;(Pkat~ z{3ACDmE;qqynV~0gS5ha?w(uv>;uIY6;Gx7oQ49{%Z7SWqV5{yKkmy1P+Vg_qnggo ze74opOk$8#_RJ~u8@59Mf3FR{8S%K<9z4fyh#{@Gz_&09cq3vRpk%RWON2rs%jwgn z6_`Y9$0R!v#P+t9cu5h^L`$3aP@Qo~Jit1Fm6_mMywK8AjPc<@*(ei%*G8`{$)u>5 zn6byo=Z_yhuJcy9u06Dtin#y&!RG|8-8rX)!IrYaP>I9sulybR8-2#OzT*&ji@fnu zZ0Zg+pPwdVGHq=zu&H5wiB-aIU{Oh9%>{pO?L|7w9i_Q1W_ydY_pdm%MRRi+pC>?t z@dK!uJP4la{<+w4q9DseQT{i(SIiFkp+v|{!==rVP zKLtvTPa9udDEi$PKsquqQnZPUF(No~CX9@OSqg{>;I-C!JF8B$r1+>wL(BxvrG`k% zLGw9nDao^EQPi~0t|kgg<3v4pC#v|cJ7J{KQHiFc(@dY!Rn1HTjgAo2$<|`0)9y}J zxoc=hLQ3lC;Zckqoj-f${R4lC9f0fUv4g3AFGk;pvak-V^>LvJtCA{d690zp#0KjP zCgY$!(pW9<1xhQE#HE!Ce0XGBVaLqC@Zd+T9^ah;BQef3wFFU$OP3yb7Aj&mYHMpF zMrpkFpJpuqE*}K+30sZ6D(bcGVr#n$)>punx?l<038#?t>&*0ym`?9*mvwy?bMY_dfxM0*{E05{ZB7$I{yBeDB(Tc;D2~{I31}HFQ1M%Ka{1(+JYrO zQTxu3y^zYQo{zvcV=dzJ$C-rZ~llI%hQ7u zzxlgrcd+G3>UW7PeNXF}nx=Wgu%m3R!cth&#uM_?>H-;Tq377?y7tcg#BhX3>S~^K zB@qpZ`X!AT1^1~(!L3`09v+9q?0BetRUDt;j%i6LrqRJPDO8Ap^Rq%*523Xh9Moz5 z{h%w9lpJ`gWDW}FWZJw@)J>;>MLM@;Hhuc^sjaQ8qeG2cV712X&yPGmzf%ai{rU5! zp`ju8;S}L3e)$bakt+%zQmm}3kGJmQpuVJaZ&HzDzLrg;&gWzsZTBd^NzVpSbF2lR zB%iVTb)Z6jZfs0TOJgH*(kHdr`7!d{k?lLX zyYmDW7ahcrjyYCO#EZZ*`5pp@%&>CWoF9ngf0X@P z20aQfM-&V{(Q8+Ms={MuHK(9Arb1MF_WK97yH(Tf^?!JzmbdJ(I;o(?$(ymDXQc0R-CgsbI^+bp|J}HYl_n&vp zBU$<-zr*T%zr3bkBu$gPe4O(lVGk|XovcTy^;W+U@>bLN7Ul5PZcDcsN3y8s14ntm zTRCTZ|Neavy1n%|&3wa(yH!pAP3Buz6Qk0Yu>9}cnlaoyNdbrFA(L8tE7JpWN z{E}-_v->E~ljXMl&Z~T*8cS?`Jnmgt#AQrXv$bJg7Dwy;f=0%ajN#Jj>hAt8!R*urVXYWd5h`sOCk!A^XHd=VDR24{5U*(cjOI`PNupPx$+=> zN`C%d70YkmzP-?z_AN?Mp_1_0AOG|c%*+uC9n8XcY2;dIeWE$+2x!RaaL} zUgP6KyJvH6Z*QBcG0(-)e7F_FgB~W3XWkIQ7Y+yrNEeH@Q>6rk%g6n=ui1jXL{9UA zN;j2Kx7P}3+(+$k%oGN=?Isn>Z}||Jd5lNF9FkX}uzmU@0sB<@n`<(-fBFh+ENY{YLue&v zjxs6u4eE?Dt!ouh*@_+|Z2|bBAHLYOk=NZ6a@A7fCui;k1P1aQ$l*hR zz)|^qJAK=fa7SDHL2^{KRVcn#KqAigI<(^R@dWqGD2!IE(B9%V1c#@abQBGtr&?>G3tZ+$Rt zX@3IQ)c4+h56#-5L7g@MeZx#xP*CvNwUsWc(x48&yX55LqVqH~wPtM>Tl@+j_aMm% zQ5?H7yjMP70-(JI;2w=er0_nE4JZt&?O}+gGz@ik1q-f{m?hwLevkc%TEagKQ{`7)(UrMI@_Qbadx9|SOSW?W%j&gHx zc|)w|7IpCENdk}e1qya_+Nuq6pD_AXR%k;7&MY8#zsy-Q31e!T{GPQ z0tsZJUtdC(AmM{xpiJrnHkp$a$ktZnv;Z2ky@yA&a{P^_4?xh?p44Bcs^N*CiCq;B-x*iw*i1C_@MEX9F1$*?VyVU zAukD#%AZfwg9i^LHI#e+I9bO121NZ=nG`aA+H9FcH%r!bo>5KZqmG2ZZBjK+dd#Qk z_IRiOEkl6n!lm`vzK^f< zo&vBf0OIvFS5{*A%oHJBx3k_55s}!Lj6^fIO@_-U`$C@Zgyag*eFS2Nu955Q8~b=U|EWQSFgfkPNU^L_E)|byUk&tG^mY> z&Ad!S#Yy<++uOO3nyMXfh^&VY?ppWHUAbvqVj-Nl+(5|N{3OwJngU5Ybi{ykena9H zcbM%2if|70q>)t3{1-%`1&G>QOw7!8p(F@!1&l+;S{v$fAZt;Jxj6t*AYn8!ZF)@s zI!|e-6G$C3?pr#x@_J|dpkn#-=~D!W)OuD?pFD$)Pa$C5i2z%vMIc&)7rDZOQ{_T= zG}B*GQ-z%laMG94-{F-F3IZeu1wQ`KjCsX#^nNd1IL&oUeIl07%F-=r9g8vc%346G z%aSx}8yk7*$mw|Bz}=riRQP5vHfc zA$YmKiV_aPe?zKli{Xv&V8bH;$(Hl4ihMu;cyjFG8fZwP)vh0?{rbzFrcyr(VI+cmp{7|LgFMQcF|fpi^n zT95q7mAv-Q*RKVvA78yZdF|kUEzx6DL3qCt)(H$|wEU?Il|WrD;3No{;VfrUlG+BX zStq~n_Kw3AfByXWI@Cal456@vaE~w=XOznaTYxV^V*g$W_;?=ziy83C zv(xVUphq5pn~JY}L4|tncj*x=QGLZi2zoD6!i39cZ1Ko5=R@y^zY%p$&)ABm&DliqRz8byazYw&DQQ- zHQkMYsE9&MlCUmBlTdaKB$P(Bo0ynjwKB!-mfI%R-@AOn_MW`FJVZ-Suu@C1RG|8{ zLd46jA}=5I&c)4*V^T;$LZaIaYC=p-u-rb+JIn z`TY4#P-I~ZMxcy4SPnoNn=S+K3{)2Qui0HV@BBkHVPWAw z0@shy#Z0T((LZ!Olu$Swy-18ag*vUFto%A7<566fE}DZq_ziJ-S{m5Ch^OWcu&kCO zut*NWa*$+TW;n9X2S5=sqrY}dBN|jZ3kz6(ArYtf*_oM{>FG^S5-x*#L*-oN$VW`6a1!%)?_JIbG0gQU7 z?r*eI77t>9XDJ0T@4Qd(lZ<8b1$>(gW((@SDei9sJFbaF^T)bWapDHOZ2V7LGp>0i_-FtXMk0xboKzi9)eAmpRh zmH5jc%7Gtc2=i(M`azWY2_g`OL-{9UL49!40^K3sNBe`!pAYUyj*k7YIPgI(+ry@M z<;4z5cZOO_$|%hIiFkjGE^mOhg_99TWC%`6#{(+OsFJW`d5PFWe`D=Oy+FL5Kwa?X(qvaG2W&&OYg%ph-E+m_4D+v zcV`1;DX*%EPbjtQwFDvrRgiK(9xzlqqRVu?XlaD+G2eua8V@izVzKowBB*M}Tl#|F z;|Wp>$o(dtq<30wnp410$9_$*xF$6WTB|8^_$rrXzEKV&NdKOVC{3cv_NxcNRFal-cZOk z1l7D0a{qQR$olrvUsu-FAW1I)#7ZZ)z`Ew{fMP-OI60-yKz5M4e@}pt&}@NKQ1A!R zUla?WAwPes=M$*#XK{TYL+m6?rh-5;)#5 zj@PwgKL7B#Eh`h#*LNa9TwKaW!Dv4huOARbQc@CZ zQ?tCO=_HT>tY1GA`=RR9A`hN1xdIswDh*h!ud)0YfbQT<&`qmNKNC4KKt;X>IR8LL z7`;sK?+KtKAeBOT%;zz#eLMab`7~Q2`fw^L!~)Y$2kBA7VKxaZleDR2(21Le2%t~` zt3BL(+~NZ2XM)Er6vaH{<(nZEj(V)AC^)yYv;eY-i3N6rsony#New{})J;U{jNvv& zKmBc8Bz%4gc$?@-BPq$+0}~Tq*(|=kxM#C9Qk$T;fkP+vhE@O7fvX3Pj|o zhq3!Y2~6hf<*RfoEKO}~N=<5j{jW>9M%~)_ol{^`Zd>1~3ck;@@e%M`8PM!jOn!bo z8Wn|;T7PeL;KR(~aFrQmUhv?t!i|4ng$m+irz+w{GkMtag*jJB+0G!8M z44W59!4KVIB4`@Gp|n&2=IUin?y_&`4F=r$0%g;>jUNW@-@gY&zq{V8hNUSt|IRQl z4(0`oC;AjE%O!}Pp&Hoh+~#k|$+^3`#~b&=ynV~%sz&HVvc}Y7R!Qo*7D3Ul9BA&b%^^=*X=I&f+A$kbGlg zbSwd)Y$cE7_M4d6z|1l<{SgHC=uHva1Y6)+pS0?mlao_b)YR7BUan`{QPBn}tM3zYm{ot^4>o2#po;vO!bEQ5Bu55lAcX&@Q<>TGIgsrmPY z>g|VDFEhyiTF{!iAdE)i7yUbLP#o>6Yg^2G%NLNKlUFt7posDLv9#$STOSP~mb0drdb zt2VVe>7Y;mv+Wbq0|^}nA@tb&U2Wr?Q5wQTd1rYbT&>n~Pkev*6IfxQxM!u{gr*FMx|DIEiZwYz?k1@6$9p3g|@+F7XTf8_@qpe^N_$J zCZW_Db=YkDw{~wavboH2&uQllbX1tC@3BD%hA}aet1W}|g8p;kTeoi2xNU&zvaqi} zp*w~sYvGpo{hi4_Jz3qkdLJOGWB89PM(+pc?Ch2`kffkfK@((nAk+oH#vluTz>8@3 z0Ck{mk(F60AcU{hd$bn#fDp6|fX8z9ftTs-GA1u#bdADKb1NY{AsV2rGe&-JqzbSd5EMl>dNd<0m$btU z?H?RqQJnYDDS$SrT6vD4BNs$FeXukpY;#c~dDa7q1+}zF1ZcKMg9(g!?OA8}Zp?Md zVM`;Qf*w_h5sh54ze1P~lo8~Z)gha@jrH{&aeCNcpXs; zU>m_FEnfO`0-g<^-vaH9G#^fqI*GY^917^{$H&Ko&@InJ4-lrbH~#_ffKpn8>sor_ zs^3kR95Cc`_6@ww)InwCXWKnKtyZpzU_{ty{TGxO1 zG6Ni!`s%%pU>rQ;92m-nOq1qiF|S^|>ckC}{lyq6$}q)codY(4(0PcR&z?OaVWIU{ zmcmLvkMUe@PTyN$du3MdK0O=4#cE>M<=WZp4!kD<-1eXbX z>slBNtzQ%YHb2wuBRg$;ena@}+qW&g*n!2UcPjKRZ*6rr=)>S!8Q8TIxN|@WpY}zc z`U9fCT-IbX;P6%p5+hqa;Z3hH~ zxP)3jeQIJGT2@xMjdP@|s=)db^?F4)9@ofX1K-pTH%8~3@oT<}MCGK;<{HU2+PJ>- zH*ell@lh@SOvVXSfk_jP6*M+Y+P=IAs$2Q;`SSueF!_1tb$k^XT2{OXDH%iuWqJ7< z5Jt?+Gh1&CRP=Zw;#!gkq`BC1DNhcK6hF`Iq+lxoKN4F2Fy;x1FNkTqcGoTb7(n-< z1;`p`DV%27$YnsiH1${mya7HRp)u+g zDk^5hmRXarNSLy0kBwG;+>;rOJ+ltIS+GqNfaY84{rvVdi%eVUeev2>w^x2034de+ zdb51`Agw^hvDRaA69zL$1y6W_9f&=EEkHHq`i>jFdyxXn?)qHBI*2(AVANnBfaif? z$zw+Xk)j`ja0IbIOA{a#uG5uJC*GR<{tNPQOxFa6B(FoPpTXIR0c|eWFj1|Dy0gxf=nE`kXlpCFN1quX0z6uHPO1d3%$=FRnwyQdf zEK6kxQN5A&AfO-yrB>_(*t6*apP*Jg{p~=@NczwTmQugiTmxi;p<)Y@xdSMXeg5?6 z*LOMEg)VH+j&XN&D5C11Zan8Qlwo5o0?q_XD~?z-mhW~~R3nFLl-Zg#NeYurR!`du zFbFZogv8?8k`q4wgPz6x1o3;s?N45Z_@44aWLm4xh)XIo#v@9(0NeL#92|-flMu;} ze8S5i5)d2=oI2(fhpQE&O}-*1`rrqHPVNPoC#dgLpr=Fp?MRd`4vLxKHM~>hWW9Td zuSP7(_S>6lsUUcen4iSKDb_UTUXqGP5Jgld<+aVtkbnRM;GxS02nbrG0h}%P@ar33 zw#E=P>{g)|y#>Ll9mL0p_Xu*Me&f2pL@V4p)Qm5;&Dp zvu81bx-cT<$1SN`2A1V$2%3ORp;lZ8v>Y81Da1;loD$R6#+u-g0zE*}k!FJ+hqByP zYkeHi&A`x5vI6aYo!#Agt`UXd>y1YP#qFQUD4CD!gi*xHze$E z&0@-*$%%!Txn(HY6TC@@O1Tb?lU6=%i_u?w;MT|oKcj&)XbS*=KpV8kovJ+m3P#9$ zY?XBuPlm@(Pg+me>DfoJnHS?B1kH1T%?c?I}23Yv1r2R8{uAmiqn8bnO|EPg=iKsBQy8+}-r-Y9+Lo zc@a2=1`xeR6_ zkj*tXtRSC7c{r|3t6{>(vWy0J{h?<*Zy=m4O14+WCvg8X@X$0oS!0a04k51jH6-sRLczbigAWYc{^L#2ao0 zdOisbWaEKsZBC3E?lg20GBPkg6k^4-TqhwqKHl4W* z!pa}$^!u?`y#uN6;kI;o8PhL-3Ki8mA3sXwJ=cXiE#ThC5M_+nm>nCnrt22huo7h=ZAUxII5NHSKP1 z_ZN{mE|EvB3IzrQMKWYzF7dHOGPlQtrj7b8@a-!F;5f4slj+*u$%T@Mk8fx86~#wg zeRjE~iCii?%5NQ3w{GeDCv^X8Y4#}mHq-n|mwMfhM1Bmz{-?GgN&3dvlv28XwJ!Z< z=Iv1Lkj^G#B6N%l+sj!!;r>8)>y1PFTA$=&JhLbH9GDi>L9g6=v=KuBE$BiUps^CqE{1I>8dO*e2RhgfLk}@tV zENT5;5>??qt*1LE$$*G>LyL0Hg3_D~c@L@O_Gb5jW>``W0h$IX08oelaRL+}?VG=% zA|g`kUN){k;EQJ0d-h*hd3kR3U9Y@5UAflw^{$QuHSL;6SwC;|0i zxGT1){=zpWH3*QNrsFO#RwCR_Na=J%EgCuW0EnQu0Yna2jpm)wID6pO1kMow(5d{uk81NH1O5 z0$)+2CT6?48jwH~etK7-o+ zPzb|(K~&gkm-mnjA*&!IAjms)4s+;Bmo8yhp@;(6*&W)6+T|v*h#ub90c-=o?koYp zZ(!*_1dF8<+#*&$$vpTbvLp(G5Nc!zqOPleP)(&lW5qp3eVK;l5QxW1Kfn3WI^*XP zFV0eR8805y8)8>CHn;%_iCSVZ5FtZw?tALXo=hXxDROqhimEC(IXR`QW#|jK1Fwqa zHUO?Blcmot)*?+e- zZSoNvBW!6E=q(`6NXy7f&4F+aXxia-4@i;^Te^`%Zgb)ateNCFD0wJ611U!qsWd{b z%0;d_be|=mk{fUp=~{IggX?djq8uQZDoBU#SPvG5%PBAv1!!HsB$zHleTU4H-NTlN zm0GZP!L*&#V{gx@{DzJ&JyHTv_M}EpdDeXe*rv8LO0Zrid@&UjKuIWF;eHvcEO76m zIAR6;!@Yn%;DjT_?RSbO97j~Z&jw8jb`%kSS23r;z;1N_+dudm2&LCBVGxp`ex?q! zx~K%x9ZG4MG+qsVh8z_6M)d0}E=f+?=5vNBE< z#0xkvR^fA%_Fqxk6Ic3fixZuL_PLL3ZJh0|p#SeEcTBRpJnjzgss1AT3~?eZzPduf z5O!7x#-qrN3i>%bOipc}>Lq&|-Y*J^h=5?E$?BlIY9F4csv0M?YLAra?gdYtg|-I& zhF3?b`+pol{Xdgu|F>sY{|_IJ`oQsK;@2qHe8o>RLJtYL4JiLYD54Zqe%cDY2 zQdklDd61rnsBfr8U@S}_i$|1e9pV!b#tYdbU`v3m&p;CsND&bc5uls?fk2g+E8?;| z2F8<(EYQ&#pwiTwda5upGgH7f1e!4I!7b*7tIFczhd>H|a@`Td7&FoZbZj6lEv?WZ4{5R@kt)wgWSU!bYrH55;# zff#H7&Rwzy&yWn0SuxuXI!9R364duv+Ejp z0umM%7jKkD=Iw8A5hvM?Ex;CPYY>xDEfB5^qNFXP9U<#MrnW@S%~*?GkW|uAZg}pl zL)-H;lcr`JaL`?dhu(V&LRn*wc$B-wpm2n=(7+KHKqnvoRUxI@uu^ET0*PZ12oR(< z6=h{Oy4>f)_I?f03DtE=Eof8{-<@T&zIs68wmDBhK`}Qq)mvs`980%E59cQDUHu6Q z2Q8vV73I;_H}nPyZc+2BA~8|!(6~eqWyZCD#i9X`15MS3f zHjqOp04WWTk`R-CfL5R*G~YVg*+qN-WTqzz03MqML}4Al`{WI?v*rP&7cPuL0-(9} zpb=Olq*{dZ$1?7yA9nW;xJ6K5J^HF$?e)T}eiU11x(aJjl0nxHQjLT7ZB_w!48783 zORSx()v|n0RZ*cpfbj?2P+nX8A{br)Xh0xv$qsFTngCLy`)u;dKCu?!$tv^v`ucKeWp+Z31!&)IaKTJC zOB$h33Mv%~s2_#ld|5~!r>UHlX%TBsxAQg<1EqkdU&8R)jhK-Q65 zUcxa7aw-^BB($HqxVp9!?RKk&(4B9H;Bq)>IYE)J{FmtbzdU46s9?zLOjjFDj@Fg46fY2|5D)ebwqtG`RU3Dv5) zo8_M!|1hpPdFn{hPOq9q}pL?U(G%ZhomqeZ}^p*vC%;oVvST;J#UkK4j6F zPMM_JLo-}5@+d!a4uOSBVFYvIDHLy0LzC_!3FAfn6}gSfyn!zFD1;zYK|>6xd1jm z@(FA}vrw#4)c51>#6Sp}%z!OSr~O)-`ufFx49bQtch9WH)gqiywWzk7uDv?(6;~^shT2)cK`Ooj9$YYG3 z9wcEy_$YZU0p>?c+h;%gzH6uA!leNj@KW(+Svd-o7c+6K zFAQaofuBVz!#5We;ZF9ckbfD+AH)nEN9U6lcWKC)V7-ZAF2MSlo_vP9z*3Iltfv{g za6SlJsz9$&Cv4;@{ObBz^2|s2e&mTcw9JvIn36+R$7Bf+=QF`3;bY^u0LJ6WF?7XD zKxemA8B|Tg7&(!AavT-3LvG)?4C){$wMR$(bbD3&R2R|1Ov4JDctI7{)fS=d&@Z2{ z{Y4;Eq28#|p9;%3Jamsa)v*4H;v8ZEx*GJS?nPQYQ+@V~hvQ3iHD7;60BKc*8f}3U zqkFlVqx0@Lf#{|W`Ve&|)sUsZ}>gh9|7&e)GTd%Sj zDI-`QcUBUqoo!cu6{{zPZ9s^n-8-zNjD5)!wqxUI)jlD~^TEN={G3SZoof%jUPc1w zS7ZyTxZY95t!c_)dS9fxRROmNgTskFhA~uE%#_{J|HA?Di9}la|KLbGr-g+-8s14I zp_FD^&b=dl_Hh)zoCphIiqIpl+WgICk|ix~3>eEt8B8K9hzoli*?>E9oc!#~49mZK6eI|A{%9$nWe`&M=a}49o`w*fL2#f)xxM5EHnx&)741fW?qe zB3>CN3j6*4d<{|jm;Rc6e_Ilw87E)vsp*j^k0X0%#zmGb{JsTG%)bMSHUyZ>m@mif zrY;HrruqD{^F9Z9*{7x&s~AN(p+s1s<9nx`9$x~_J&tJo z#xF1*4Hy0yTr|w7KNdrH+I}zJ=XfN@LA1TLJy?f6EV6+<3$U{D$|<@dFmrW$WEgOZ zPj#43%(7m=DLV>!SO>~{0@OUmr?uN_u)rAIOD0XPkpl$fm*B&jQK85jIQ)(c1gzZW z-4xh#cTP8wBR(Pm#qm8y11P)#8UKn;D@!u1)J|H=1` zm$Fop%hK7jF}m}v7ta^i$`eP3$Pqsfr?cW-Y- zacYWVwF4uAB3o555=Ex;8y5~gY~dN7i_TDGZ!N1Z&U%JbCQVm;7bjF>4c{X;1)h9Q z%_7@=qb^g|yZ*pvs=C0-ac5TMWva7mj$x#&@c6gb%QBhCf!^eqZE^aa z_&8aHt=m_w*h)@4(Ix?wi$YR_qv{h?44y7mch33_#ifQHMk3#5%pdGba<`YVRoPuH z7fZW&ne_EHnjhahp*sx^3p`Xilf9LBr* z%gkB5t<#{Scw|kTh=EJ(hVWq%d8kSjbUu$`0)MPY2u*p+uNYRJOcpjt`CgFkok~j? zE9=xV`b50`u-yvTdoFMT5&2P#rUJKcMK#@?=$w%1rQZ?WPD_WHuIZVE(>c|iln?D< z8IJO)0Aj=8_vI+;LAttD&XQ&L<2v@k2L90)u0oxp&=(G>q!IfmTu^v&;z`l@QZZ>7 zxsd(PAhmpFC*k60A8=$JYh-KaJSmk!RBE?xziMTDlyT}Y;b;AeN_Ejkdq)Y5_cp@T z?6AkSZESMh!ERf_q;>d7;x|?2C9}Q8Z~TBN5}p{vv8UhkEk` zDZsTbav=Ig?@-x~-A)}2>8a8XVZLTf=P-H43|sKp#AqD}S$HtIB06?~xphKTLoq`$ z;fbZZeZcBBGA2qvuckXgt+@D91PWzPRP7!1-j1@d zuS1_yVm4GfW8lRvextrvvHYU5SZ}&V9}i>~pN59x_C>lYUcd0}QOUH<9cDy6Vb!75 zzn*maT3z}fzR*PQQ|In`4VY6hnNo*Q4(#lqa6A?BNxuB8?Tqs4>h~On+QnNG?tIg& zGxQv{Zy(S&+TS*B@OSCv*deW8NBV(hPrm$kqMz=6%vE(yz1_o8bB+FL-7{Y^iDydP zo-1GF^yWpCV_f9#K}+q-Tns|O85LGPy8KFREs z<)0B28!uZ`_Qw-glr7)~#?(yjUvGjAy}Yd|rxl#+WSVc5WD zI{T-kMdYncu=@m|^eOJiO&4!Io>#2o)?c^()>|*#kl4=$UnF0g;|6S{eQ6GMV@!GU zYR>Ay-G^4~kKcW6&aMBy%@2EuR&Qv@-ZFXp_W4>ffZMTypY8)49O?k-GHy`&GW&w` z{kX6D&abc7%}_Z@X7-y$pYP`xrq0(8{&iZkK41QEZt|?PHa2sZm}Qr)^eI|nrR2Wf z=91k1SGTwSU8@@?9?m|S|JT*eyVn6r$S+O6K7-WtN9+Eb?Q)gxy?43n`rYS#r_NqK zynNgG`qxRPCOnE>oe`AL^f2twzXgAn`3kK&t|`v{)PQFq+tjMm#hEIGI{)7NU$Wvz zz0i~T@7wM^ew6&Jf8oT*z&>t6958c62wyOE_;+Wo!i5!DRqF141x0rne)@LsM09;C z@Qf<)^nM5b@_hd5LTUeJ?5GI&f2z^%=$91_9>yNNzV*cadkdpIWn5}1V+2mq_Q6EO)3y4PJwxUTo-Hn3bNX^gL8xS?Vy3@n)! z1Yln_Fgolwnv6hMiP8y03KB6DZGZJ2m=*s2cWB)X>bbCinhMMeiX}U;xikLX1bNfb L)z4*}Q$iB}m5-{I literal 25680 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 diff --git a/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-shape.png b/apps/desktop/e2e/__screenshots__/tools.spec.ts/tool-shape.png index 762db9be45c4d21b7b08b19ae6a5f2d456c580f6..0f376dbc0b5c8b298d05ec46108b39bc5b3e2dc5 100644 GIT binary patch literal 22952 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

iMSFn(oH{dSI#TK5&{{jKQ=8Sz6OMpoSD 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 A)c^nh literal 25621 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 h_B7mLC1O=j3#xrAeyt)K>({ try { app = await electron.launch({ - args: [MAIN_JS, `--user-data-dir=${userDataDir}`], + args: [MAIN_JS, `--user-data-dir=${userDataDir}`, "--force-device-scale-factor=1"], env: environment, }); - // Wait for the startup window, then set a fixed size for deterministic tests. - await app.firstWindow(); + // Size the window that owns the test page instead of whichever app window was created first. + const page = await app.firstWindow(); const activeUserDataDir = await app.evaluate(({ app: electronApp }) => electronApp.getPath("userData"), ); if (fs.realpathSync(activeUserDataDir) !== fs.realpathSync(userDataDir)) { throw new Error(`Electron ignored isolated user data directory: ${activeUserDataDir}`); } - await app.evaluate( - async ({ BrowserWindow }, { w, h }) => { - const win = BrowserWindow.getAllWindows()[0]; - if (win) { - win.unmaximize(); - win.setSize(w, h); - win.center(); - } + const browserWindow = await app.browserWindow(page); + await browserWindow.evaluate( + (win, { w, h }) => { + win.unmaximize(); + win.setSize(w, h); + win.center(); }, { w: WINDOW_WIDTH, h: WINDOW_HEIGHT }, ); + await browserWindow.dispose(); + await page.waitForFunction( + ({ w, h }) => window.innerWidth === w && window.innerHeight === h, + { w: WINDOW_WIDTH, h: WINDOW_HEIGHT }, + ); await use(app); } finally { diff --git a/apps/desktop/e2e/glyph-rendering.spec.ts b/apps/desktop/e2e/glyph-rendering.spec.ts index 6b17b140..4bd33377 100644 --- a/apps/desktop/e2e/glyph-rendering.spec.ts +++ b/apps/desktop/e2e/glyph-rendering.spec.ts @@ -153,8 +153,13 @@ test.describe("Pen tool drawing — segment snapshots", () => { await page.mouse.move(750, 250, { steps: 5 }); await page.mouse.up(); - // Segment 3: another straight. - await canvas.click({ position: { x: 850, y: 500 } }); + // Segment 3: another straight, kept inside the measured canvas bounds. + const canvasBounds = await canvas.boundingBox(); + if (!canvasBounds) throw new Error("Expected interactive canvas bounds"); + + await canvas.click({ + position: { x: Math.round(canvasBounds.width * 0.9), y: 500 }, + }); await page.waitForTimeout(300); const canvasUtil = new CanvasUtil(page); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 27539291..561ebc8c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -16,7 +16,11 @@ "test": "vitest run", "test:watch": "vitest", "build:e2e": "tsx e2e/build.ts", - "test:e2e": "pnpm build:e2e && pnpm exec playwright test" + "test:e2e": "pnpm build:e2e && pnpm exec playwright test --project=visual --project=gpu", + "test:e2e:visual": "pnpm build:e2e && pnpm exec playwright test --project=visual", + "test:e2e:visual:update": "pnpm build:e2e && pnpm exec playwright test --project=visual --update-snapshots", + "test:e2e:gpu": "pnpm build:e2e && pnpm exec playwright test --project=gpu", + "test:e2e:perf": "pnpm build:e2e && pnpm exec playwright test --project=perf" }, "author": { "name": "Kostya Farber", diff --git a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md index 02c41639..f1dbb4ea 100644 --- a/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md +++ b/apps/desktop/src/renderer/src/lib/graphics/docs/DOCS.md @@ -120,7 +120,7 @@ Set a breakpoint or add logging in `MarkerLayer.draw`. Check `isAvailable()` ret ## Verification -- Run `pnpm --filter @shift/desktop test:e2e -- --project=gpu e2e/gpu.spec.ts` after changing catalog residency. The suite samples the catalog while scrolling down and back up, verifies that the complete atlas never hides or reloads, checks local replacement patches, and requires a complete rebuild after global axis/source invalidation. +- See [Desktop E2E tests](../../../../../../e2e/README.md) for visual and hardware-GPU verification commands. - Run `pnpm test:desktop apps/desktop/src/renderer/src/lib/graphics/ContourPath.test.ts` after changing contour command, transform, SVG, Canvas, or bounds behavior. - No dedicated tests exist for `MarkerLayer`. Verify GPU markers visually: open a glyph with mixed point types (corner, smooth, off-curve), hover and select points, confirm correct shapes and state colors. - Check CPU fallback: in `MarkerLayer.#initialize`, temporarily throw before `this.#available = true`. Handles should still render via Canvas 2D. diff --git a/docs/architecture/index.md b/docs/architecture/index.md index 721d34a5..6c16daed 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -30,6 +30,7 @@ Central routing table for Shift's distributed documentation. Before creating new | `apps/desktop/src/main/**` | [`apps/desktop/src/main/docs/DOCS.md`](../../apps/desktop/src/main/docs/DOCS.md) | Electron main process: lifecycle, windows, menus, document state | | `apps/desktop/src/preload/**` | [`apps/desktop/src/preload/docs/DOCS.md`](../../apps/desktop/src/preload/docs/DOCS.md) | Preload script bridging native Rust FontEngine to renderer | | `apps/desktop/src/shared/bridge/**` | [`apps/desktop/src/shared/bridge/docs/DOCS.md`](../../apps/desktop/src/shared/bridge/docs/DOCS.md) | Type-safe preload bridge system (FontEngineAPI contract) | +| `apps/desktop/e2e/**` | [`apps/desktop/e2e/README.md`](../../apps/desktop/e2e/README.md) | Playwright visual, GPU correctness, and performance suites | ### Desktop app — Renderer diff --git a/package.json b/package.json index e45b38b9..6f9da3a9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,12 @@ "test:integration": "pnpm test:native && cargo test --workspace", "test:lint": "pnpm --filter @shift/desktop run lint:check", "test:perf": "pnpm generate:glyph-info && pnpm --filter @shift/desktop exec vitest bench --run", - "test:playwright": "pnpm --filter @shift/desktop test:e2e", + "test:e2e": "pnpm --filter @shift/desktop test:e2e", + "test:e2e:visual": "pnpm --filter @shift/desktop test:e2e:visual", + "test:e2e:visual:update": "pnpm --filter @shift/desktop test:e2e:visual:update", + "test:e2e:gpu": "pnpm --filter @shift/desktop test:e2e:gpu", + "test:e2e:perf": "pnpm --filter @shift/desktop test:e2e:perf", + "test:playwright": "pnpm test:e2e", "test:watch": "turbo run test:watch", "test:native": "turbo run test --filter=shift-bridge", "typecheck": "pnpm run generate:types && turbo run typecheck", From 0297a8b92930453d8ea0366e7f5b4be649a4b41d Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Sat, 1 Aug 2026 08:12:22 -0400 Subject: [PATCH 29/29] Generate glyph info before E2E builds --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 561ebc8c..5547bd67 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,7 +15,7 @@ "lint:check": "oxlint --deny-warnings src/", "test": "vitest run", "test:watch": "vitest", - "build:e2e": "tsx e2e/build.ts", + "build:e2e": "pnpm --workspace-root generate:glyph-info && tsx e2e/build.ts", "test:e2e": "pnpm build:e2e && pnpm exec playwright test --project=visual --project=gpu", "test:e2e:visual": "pnpm build:e2e && pnpm exec playwright test --project=visual", "test:e2e:visual:update": "pnpm build:e2e && pnpm exec playwright test --project=visual --update-snapshots",