From 2b63bf1b67c648823b9ca9d3d515ab9e331f4226 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 06:13:40 -0800 Subject: [PATCH] Type: keep composition spacing along the line when characters rotate --- crates/text/src/layout.rs | 10 ++--- crates/text/src/layout/wrapping_tests.rs | 50 ++++++++++++++++++++++++ crates/text/src/lib.rs | 2 +- crates/text/src/tests/typo.rs | 22 +++++++++++ docs/roadmap.md | 2 + docs/target-app-parity.md | 3 ++ 6 files changed, 83 insertions(+), 6 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index cbdd41725..669777687 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -285,21 +285,21 @@ impl Ctx<'_> { Some(ib) => Affine::translate((g.kern_shift - lead, -g.bshift)) * ib.xf, // Baseline shift moves the glyph across its line, and Character Rotation then turns it // about its shifted pen position, so the shift is across the line at any rotation. - // Right-to-left manual kerning moves that pen position along the line. + // Manual kerning and composition trim move that pen position along the line. None => { - Affine::translate((g.kern_shift, -g.bshift)) + Affine::translate((g.kern_shift - lead, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) - * Affine::translate((g.dx - lead, g.dy)) + * Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy) } }; // The turning point in font units, which later moves of the glyph's transform keep. let pivot = match local.determinant() { - det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift, -g.bshift), + det if det != 0.0 && det.is_finite() => local.inverse() * Point::new(g.kern_shift - lead, -g.bshift), _ => Point::ZERO, }; let unturned = if g.inline.is_none() && g.rotation != 0.0 && g.sx != 0.0 && g.sy != 0.0 { - let placed = Affine::translate((g.dx - lead, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy); + let placed = Affine::translate((g.dx, g.dy)) * Affine::scale_non_uniform(g.sx, g.sy); placed.inverse() * Affine::rotate(g.rotation.to_radians()) * placed } else { Affine::IDENTITY diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 2e036c3cf..9f1a048b3 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -49,6 +49,56 @@ fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range { first.byte..last.byte + last.len } +#[test] +fn composition_trim_stays_along_the_line_when_a_character_rotates() { + let db = FontDb::with_font_dirs(vec![]); + let style = CharStyle::default(); + let opts = LayoutOptions::default(); + for angle in [0.0_f64, std::f64::consts::FRAC_PI_4, std::f64::consts::FRAC_PI_2] { + let origin = Point::new(100.0, 80.0); + let pre = Affine::translate(origin.to_vec2()) * Affine::rotate(angle); + let draw = |rotation: f64, lead: f64| { + let mut glyph = synthetic_glyphs(&[(0, 1, 30.0, 'A')]).remove(0); + glyph.sx = 0.02; + glyph.sy = 0.02; + glyph.dx = 2.0; + glyph.dy = -1.0; + glyph.bshift = 4.0; + glyph.kern_shift = 3.0; + glyph.rotation = rotation; + glyph.lead = lead; + let mut cx = Ctx { + on_path: angle != 0.0, + db: &db, + text: "A", + runs: vec![(0..1, &style)], + inlines: vec![None], + default: style.clone(), + opts: &opts, + vertical: false, + system_layout: false, + fractional_widths: true, + upright_cluster: None, + out: TextLayout::default(), + }; + cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0); + cx.out.glyphs.remove(0) + }; + let expected = Vec2::new(-angle.cos(), -angle.sin()) * 5.0; + for rotation in [0.0, 45.0, 90.0, -90.0] { + let (plain, trimmed) = (draw(rotation, 0.0), draw(rotation, 5.0)); + assert!(!trimmed.outline.elements().is_empty(), "fixture has drawn geometry"); + let shift = trimmed.xf * Point::ZERO - plain.xf * Point::ZERO; + assert!((shift - expected).hypot() < 1e-9, "line {angle}, character {rotation}: {shift:?} versus {expected:?}"); + let pivot_shift = trimmed.xf * trimmed.pivot - plain.xf * plain.pivot; + assert!((pivot_shift - expected).hypot() < 1e-9, "composition moves the turning point along the line: {pivot_shift:?}"); + let unrotated = draw(0.0, 5.0); + let actual = (trimmed.xf * trimmed.unturned).as_coeffs(); + assert!(actual.iter().zip(unrotated.xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "removing rotation keeps composition placement"); + } + } +} + #[test] fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() { let text = "بَت"; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index bafaf70af..1d3203607 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -147,7 +147,7 @@ pub struct PositionedGlyph { /// its Character Rotation. The identity for a glyph without one. pub unturned: Affine, /// Font units: `xf * pivot` is the point its Character Rotation turns it about, its pen - /// position on the baseline moved by its baseline shift. + /// position after composition spacing adjustments, moved by its baseline shift. pub pivot: Point, /// A ligature (one glyph that alone stands for several characters, grapheme clusters, of the /// text): the caret stop between each two of them, in logical order, as the byte offset of the diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 2627b81ee..092ed4c09 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -1729,6 +1729,28 @@ fn proportional_metrics_set_full_width_glyphs_on_their_proportional_widths() { assert!((moved - 10.0).abs() < 0.01, "〙's empty half goes past the line's end: あ moved {moved}"); } +#[test] +fn mojikumi_trim_keeps_a_rotated_opening_bracket_on_its_line() { + use vectorcraft_doc::Mojikumi; + if db().face("Shippori Mincho", "Regular").is_none_or(|f| f.family != "Shippori Mincho") { + eprintln!("skipped: Shippori Mincho is not in CRAFT_FONTS_DIR"); + return; + } + let lay = |rotation, mojikumi| { + let st = CharStyle { font_family: "Shippori Mincho".into(), size: 20.0, rotation, ..Default::default() }; + let mut t = area("「あ", st, Rect::new(0.0, 0.0, 200.0, 200.0), Justify::Left); + t.para.mojikumi = mojikumi; + layout(db(), &t) + }; + for rotation in [0.0, 45.0, 90.0, -90.0] { + let (plain, trimmed) = (lay(rotation, Mojikumi::None), lay(rotation, Mojikumi::LineEndHalf)); + let (a, b) = (&plain.glyphs[0], &trimmed.glyphs[0]); + assert!((a.advance - 20.0).abs() < 0.01 && (b.advance - 10.0).abs() < 0.01, "full-width bracket fixture"); + let shift = b.xf * Point::ZERO - a.xf * Point::ZERO; + assert!((shift.x + 10.0).abs() < 0.01 && shift.y.abs() < 0.01, "{rotation} degrees: {shift:?}"); + } +} + /// The arrows pass a Thai consonant with its vowel and tone marks whole (น้ำ: no, mai tho, sara am), /// as an accented letter written with a combining mark; plain text moves a character at a time. #[test] diff --git a/docs/roadmap.md b/docs/roadmap.md index 669327843..a0d6c7473 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -26,6 +26,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-11-03 | minor | M7: Find Font replacement sources Document, Recent and System are shipped (#5217); estimates unchanged. | | 2026-10-30 | minor | Raster effects: bounded length fields in effect dialogs clamp on commit and reject invalid inputs visibly (#4638); estimates unchanged. | +| 2026-11-03 | minor | M7: composition spacing remains along the line when characters rotate; estimates unchanged. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-3; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile upstream documentation after review F-2; preserve every dated revision and the original scaleArt runtime evidence without repeating completed jobs. | | 2026-10-22 | minor | Reconcile subsequent upstream progress updates without changing the runtime or repeating completed evidence; retain every dated upstream and feature revision. | @@ -153,6 +154,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | 2026-11-03 | minor | M7 records Find Font's shipped replacement-source chooser (#5217); estimates unchanged. | | 2026-11-03 | minor | Symbol editing shows draggable 9-slice guides (#5280). Guides outside the artwork remain unsupported. Scores and hours remain unchanged. | | 2026-10-30 | patch | M5: opaque solid-fill knockout paths avoid redundant erase/add layers in hard-edge frames and supersampled raster export; exact-pixel regression coverage retains analytical-AA rounding and translucent knockout behavior; scores and estimates unchanged | +| 2026-11-03 | minor | M7: retain composition trimming in rotated character placement and rotation metadata; estimates unchanged. | | 2026-10-30 | minor | M4: G9: `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout) in its state block after its record, so its steps knock each other out as the page draws them; a user's file comes in 0.6% from its page instead of 1.3%. Scores and estimates unchanged. | | 2026-10-30 | minor | M4: File formats: `.ai`/EPS editing-data fidelity from an audit of a user's files: All Caps and Small Caps (#4708); placed files at their box's corner, kept where they draw closer to the page (#4664, #4723, #4742); pages cropped to the art, with `UserUnit` (#4658, #4727); gradient mesh corners with handle weights of 0 and the state written after a mesh (#4738, #4744, #4748); type on a path spaced as the file lays it out (#4743); synthetic regressions in `crates/eps`; scores and estimates unchanged. | | 2026-10-29 | minor | M6: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act; scores unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 043e46017..537062829 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -29,6 +29,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | 2026-10-31 | minor | Object › Path › Smooth smooths a path along its length before refitting it, so Auto-Smooth takes a shaky line to one smooth curve instead of leaving its jags, and with anchor points direct-selected it smooths only that section; scores unchanged. | | 2026-10-31 | minor | New Document: More Settings has the Save Preset button beside the name too, saving its settings (artboard layout included) as a preset (#4893); scores unchanged. | | 2026-10-31 | minor | Object › Rasterize in a CMYK document with its CMYK colour model makes a CMYK image of the art's ink amounts (as CMYK exports draw them), so the Properties panel says CMYK and CMYK exports keep its inks; a transparent background becomes the image's opacity mask (#4882); scores unchanged. | +| 2026-11-03 | minor | Composition spacing stays along the line under Character Rotation, including the turning point and unrotated placement; scores and estimates unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as Illustrator's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Export for Screens is laid out as the reference app's: Artboards and Assets tabs with a grid or list of thumbnails, Clear Selection and Prefix under them, Create Sub-folders by Scale or Format (on by default), Export PDFs As Single File or Multiple Files (`pdfs`), iOS and Android rows, the format presets in a ☰ menu, and a Selected / files-to-export count; the naming pattern, Layers and Selection pages and folders list stay in `document.exportForScreens` for scripts and MCP; scores unchanged. | | 2026-10-31 | minor | Artwork Cleanup add-on: Join Point Text (`addon.cleanup.joinText`) joins stacked one-line point type into one object, a paragraph per line keeping its baseline (#4826); scores unchanged. | @@ -774,6 +775,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). + Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. @@ -1563,6 +1565,7 @@ A double-click on a Repeat (Radial, Grid or Mirror) with the Selection tool ente | 2026-11-03 | minor | SVG imports preserve exported ellipses as four-anchor paths (#5243). Inline graphics use manual kerning (#5245). Symbol editing shows draggable 9-slice guides (#5280). Recolor Artwork offers smooth and segmented wheels and color bars (#5249). Scores and hours remain unchanged. | | 2026-11-01 | minor | Repeat: a double-click isolates it to edit its source art, text included (#5054); evidence added; scores unchanged. | | 2026-11-01 | minor | Live Corners: Up/Down while dragging a corner widget step the corner kind (#2898); evidence added; scores unchanged. | +| 2026-11-03 | minor | Rotated characters retain line-axis composition trimming; scores and estimates unchanged. | | 2026-11-01 | minor | Shortcuts for Effect menu items (#3296); evidence added; scores unchanged. | | 2026-11-01 | minor | Color Effects add-on (a VectorCraft addition): Find Nearest Swatch (#4923); evidence added; scores unchanged. | | 2026-11-01 | minor | Create Swatch Legend add-on under Object › Plug-ins › Color Effects (`addon.color.swatchLegend`, #4919); evidence added; scores unchanged. |