From 75d2736a2a4c20e270c3c7b3934639196cbcb9da Mon Sep 17 00:00:00 2001 From: Don Omondi <282319807+mockydevs@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:59:28 +0300 Subject: [PATCH] fix(eps): area type's first line goes where the file lays it out Area type made from the editing data's text document was laid out with its first baseline the font's ascent below the frame's top. The text document keeps where each of its lines starts, and the file's page draws them there: for some fonts the first line sits well above the font's ascent (Oswald's at about its cap height), so headings came in lower than on the page and the layers were taken to differ from it. When the file's first line is more than half a point from where it is laid out here, the first baseline is now fixed where the file has it (First Baseline: Fixed), measured in the text document's own frame so a copied object's type moves with its frame. # Conflicts: # docs/file-format-parity.md --- crates/eps/src/import/ate.rs | 63 ++++++++++++++++++++++++++++ crates/eps/src/import/native/plan.rs | 3 ++ docs/file-format-parity.md | 3 +- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index a5b22f92d..6ad647cad 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -803,6 +803,35 @@ impl Story { !self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t) } + /// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and + /// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its + /// page draws it: its first baseline is fixed where the file has it. The font's ascent below the + /// frame's top, the first baseline here, isn't where the app that wrote the file puts it for + /// every font. Whether it was. + pub(super) fn place_first_line(&self, node: &mut Node, template: (f64, f64), to_doc: Affine) -> bool { + // Closer than this (points of the document) and the line is where the file has it. + const SAME: f64 = 0.5; + let NodeKind::Text(t) = &mut node.kind else { return false }; + if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top { + return false; + } + let Some((_, (x, y))) = self.line_starts(template).into_iter().next() else { return false }; + let want = (to_doc * Point::new(x, y)).y; + let db = vectorcraft_text::FontDb::global(); + // A frame too short for the line lays nothing out: as the line needs it (see `fit_first_line`). + let mut probe = TextObject::clone(t); + vectorcraft_text::fit_first_line(db, &mut probe); + let laid = vectorcraft_text::layout(db, &probe); + let Some(first) = laid.lines.first().filter(|l| l.region == 0) else { return false }; + let min = first.first_baseline + (want - first.baseline); + if (want - first.baseline).abs() <= SAME || !min.is_finite() || min <= 0.0 { + return false; + } + t.area.first_baseline = vectorcraft_doc::FirstBaseline::Fixed; + t.area.first_baseline_min = min; + true + } + /// The leading the file's own line positions imply: the distance from one line to the next, less /// the space between paragraphs that is part of it (a paragraph's space after and the next one's /// space before; a layout's lines are where that space has put them, and the text object adds it @@ -1249,4 +1278,38 @@ mod tests { let mut t = area(13.0); assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0); } + + #[test] + fn area_type_s_first_line_goes_where_the_file_lays_it_out() { + // GUARDS: area type's first baseline was the font's ascent below the frame's top, where the + // file's own layout (and its page) can have it higher: a heading came in lower than the page's. + let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1]; + let frame = vec![ + line((10.0, 20.0), (210.0, 20.0)), + line((210.0, 20.0), (210.0, 120.0)), + line((210.0, 120.0), (10.0, 120.0)), + line((10.0, 120.0), (10.0, 20.0)), + ]; + // The canvas is the document here: the art's y up, the document's down. + let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0])); + let first = |s: &Story| { + let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap(); + let placed = s.place_first_line(&mut node, template, to_doc); + let NodeKind::Text(t) = node.kind else { panic!("area type") }; + let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + (placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline) + }; + let mut s = story("Heading\r", &[], &Para::default()); + s.frames = vec![FrameKind::Area(frame)]; + // Where this lays the line out on its own: the file agrees, and nothing changes. + s.lines = vec![(10.0, 20.0)]; + let (_, Some(own), _) = first(&s) else { panic!("a line") }; + s.lines = vec![(10.0, own)]; + assert_eq!(first(&s), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent)); + // Higher in the file: there. + s.lines = vec![(10.0, own - 3.0)]; + let (placed, at, fb) = first(&s); + assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}"); + assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0); + } } diff --git a/crates/eps/src/import/native/plan.rs b/crates/eps/src/import/native/plan.rs index cc2772f8e..4bb3675cd 100644 --- a/crates/eps/src/import/native/plan.rs +++ b/crates/eps/src/import/native/plan.rs @@ -70,6 +70,9 @@ pub(super) fn plan( let Some(Some(story)) = stories.get(k) else { continue }; let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue }; let mut region = story.region(t, to_doc); + // The first line where the file has it, in the text document's frame (that of a + // copied object is its original's, as its lines are). + story.place_first_line(&mut node, t, to_doc); // Area type and type on a path in the object's own frame: the text document's frame // of a copied object keeps its original's path. frame_text_from_art(info, &mut node, &mut region); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index bab11248d..44a180f1f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (type from an Illustrator file's text document takes the attributes of its character style) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (area type from an Illustrator file's text document starts its first line where the file lays it out) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-31 | minor | `.ai`/EPS editing data: area type made from the file's text document put its first baseline the font's ascent below the frame's top, where the file's own layout (and its page) can have it higher: Oswald's and Urbanist's headings came in a third and an eighth of their size lower than on the page, and each text over them was taken to differ. When the text document's first line is more than half a point from where it is laid out here, the first baseline is fixed (Area Type Options › First Baseline › Fixed) where the file has it; the type's own font metrics aren't changed. Test `import::ate::tests::area_type_s_first_line_goes_where_the_file_lays_it_out`; checked locally against users' files: two election posters come 5.4% → 1.1% and 8.6% → 4.3% from their pages, so they open from their layers, and another 1.7% → 0.3%; scores unchanged | | 2026-10-31 | minor | `.ai`/EPS editing data: type made from the file's text document took each character attribute its run doesn't set from the document's base attributes, not from the character style the run names (`[Normal Character Style]` or another of the file's, and the styles each is based on). Where they differ the type came in wrong: a user's file whose base fill is white and whose Normal Character Style is black came in with its text white, so most of its type was missing wherever the page's own type didn't stand in for it. A run now takes its own attributes, then its character style's, then the document's ([Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html)). Test `import::tests_native::type_from_the_text_document_takes_its_character_styles_attributes`; checked locally against 377 users' files: none newly opens from its PDF part, and with #4791 that file opens from its layers (6.3% → 1.1% from its page); scores unchanged | | 2026-10-31 | minor | SVG export (and Object › Expand, EPS, DXF, EMF and the other outputs that cut a gradient mesh into flat pieces): each patch was cut into 8 × 8 quads whatever its colours, so a patch of one colour became 64 identical pieces. Neighbouring quads are now joined into square blocks wherever the colour changes by no more than about one 8-bit step across the block, so a flat patch is one piece and a patch that shades strongly keeps all 64; a block's outline runs through its quads' corners, and a patch that folds over itself is not joined. A synthetic page of 400 meshes (6 × 6 patches, half flat) exports 468,000 pieces instead of 921,600 (35 MB instead of 66 MB), and resvg draws the two files identically. Tests `mesh_export` (crate `vectorcraft-svg`), `tests::live::mesh_expand_to_flat_pieces`; scores unchanged | | 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged |