mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge pull request #7576 from dotCooCoo/fix/svg-path-type-before-start
SVG import: type on a closed path wraps around past the path's start
This commit is contained in:
@@ -1425,10 +1425,18 @@ impl Ctx {
|
||||
let em = self.font_size(tp);
|
||||
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
|
||||
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
|
||||
// Text that would start before the path's beginning starts at it, with all its glyphs
|
||||
// on the path (SVG does not draw the glyphs that fall off it).
|
||||
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
|
||||
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None };
|
||||
// On a path of one closed subpath, text that starts before the path's start or past its
|
||||
// end wraps around the path. Otherwise it starts at the path's start or end.
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
let wraps = path.subpaths.len() == 1 && path.is_closed();
|
||||
let wrap = |s: f64| {
|
||||
// `rem_euclid` can round a tiny negative value up to 1.
|
||||
let s = s.rem_euclid(1.0);
|
||||
if s < 1.0 { s } else { 0.0 }
|
||||
};
|
||||
let start =
|
||||
Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| if wraps { wrap(s) } else { s.clamp(0.0, 1.0) });
|
||||
obj.kind = TextKind::OnPath { path, start, end: None };
|
||||
obj.xf = Affine::IDENTITY;
|
||||
} else if vertical {
|
||||
// Type on a vertical path: dy is extra advance, dx shifts across the column.
|
||||
|
||||
@@ -299,6 +299,84 @@ fn text_path_becomes_type_on_a_path() {
|
||||
assert_eq!(t[2].para.justify, Justify::Left);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_on_a_closed_path_wraps_past_the_path_start() {
|
||||
// A closed path 400 long: text anchored before its start, or offset past its end, wraps
|
||||
// around the path instead of stopping at its start or end.
|
||||
let d = open(
|
||||
r##"<path id="p" d="M50 50 H150 V150 H50 Z" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#p">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#p" startOffset="440">Wave</textPath></text>
|
||||
<text font-size="20" direction="rtl"><textPath href="#p">Wave</textPath></text>
|
||||
<text font-size="20" text-anchor="middle"><textPath href="#p">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#p" startOffset="-40">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#p" startOffset="-10%">Wave</textPath></text>
|
||||
<path id="q" d="M50 50 H150 V150 H50 Z M200 50 H300 V150 H200 Z" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#q">Wave</textPath></text>
|
||||
<path id="o" d="M50 50 H150 V150 H50" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#o">Wave</textPath></text>
|
||||
<text font-size="20"><textPath href="#o" startOffset="440">Wave</textPath></text>"##,
|
||||
);
|
||||
let t = texts(&d);
|
||||
assert_eq!(t.len(), 9);
|
||||
let start_of = |t: &TextObject| match t.kind {
|
||||
TextKind::OnPath { start, .. } => start,
|
||||
_ => panic!("{:?}", t.kind),
|
||||
};
|
||||
let width: f64 = layout(FontDb::global(), t[0]).glyphs.iter().map(|g| g.advance).sum();
|
||||
assert!(width > 0.0);
|
||||
// Ending at the path's start: the text starts its width before the end of the loop.
|
||||
assert!(close(start_of(t[0]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[0]));
|
||||
assert!(close(start_of(t[1]), 0.1, 1e-9), "{}", start_of(t[1]));
|
||||
// Right-to-left text starts at its edge toward the path's end, here the path's start.
|
||||
assert!(close(start_of(t[2]), 1.0 - width / 400.0, 1e-6), "{} {width}", start_of(t[2]));
|
||||
// Centered on the path's start: half the text before it, half after.
|
||||
assert!(close(start_of(t[3]), 1.0 - width / 800.0, 1e-6), "{} {width}", start_of(t[3]));
|
||||
// A negative startOffset, in user units or as a percentage, counts back from the path's end.
|
||||
assert!(close(start_of(t[4]), 0.9, 1e-9), "{}", start_of(t[4]));
|
||||
assert!(close(start_of(t[5]), 0.9, 1e-9), "{}", start_of(t[5]));
|
||||
// A path of two closed subpaths, and an open path, do not wrap: the text starts at the path's
|
||||
// start or end.
|
||||
assert_eq!(start_of(t[6]), 0.0);
|
||||
assert_eq!(start_of(t[7]), 0.0);
|
||||
assert_eq!(start_of(t[8]), 1.0);
|
||||
|
||||
// Laid out, the end-anchored text draws every glyph up the left side and ends at the start
|
||||
// point; the centered text crosses the start point from the left side onto the top.
|
||||
let lay = layout(FontDb::global(), t[0]);
|
||||
assert!(!lay.overflow);
|
||||
assert_eq!(lay.glyphs.len(), 4);
|
||||
for g in &lay.glyphs {
|
||||
let o = t[0].xf * g.origin;
|
||||
assert!(close(o.x, 50.0, 0.05) && o.y > 50.0 && o.y < 150.0, "{o:?}");
|
||||
}
|
||||
let last = lay.glyphs.last().map(|g| t[0].xf * (g.origin + baseline_dir(g.angle) * g.advance));
|
||||
assert!(last.is_some_and(|p| close(p.x, 50.0, 0.05) && close(p.y, 50.0, 0.05)), "{last:?}");
|
||||
let lay = layout(FontDb::global(), t[3]);
|
||||
assert!(!lay.overflow);
|
||||
assert_eq!(lay.glyphs.len(), 4);
|
||||
let first = t[3].xf * lay.glyphs[0].origin;
|
||||
assert!(close(first.x, 50.0, 0.05) && first.y > 50.0, "{first:?}");
|
||||
let last = t[3].xf * lay.glyphs[3].origin;
|
||||
assert!(close(last.y, 50.0, 0.05) && last.x > 50.0, "{last:?}");
|
||||
|
||||
// Text longer than the loop draws its first characters, whatever its anchor.
|
||||
let d = open(
|
||||
r##"<path id="s" d="M0 0 H20 V20 H0 Z" fill="none" stroke="black"/>
|
||||
<text font-size="20" text-anchor="end"><textPath href="#s">Wave Wave Wave</textPath></text>"##,
|
||||
);
|
||||
let t = only_text(&d);
|
||||
let lay = layout(FontDb::global(), t);
|
||||
assert!(lay.overflow);
|
||||
assert!(!lay.glyphs.is_empty() && lay.glyphs.len() < 14, "{}", lay.glyphs.len());
|
||||
assert!(lay.glyphs.iter().enumerate().all(|(i, g)| g.byte == i), "{:?}", lay.glyphs.iter().map(|g| g.byte).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// The unit vector along a glyph's baseline.
|
||||
fn baseline_dir(angle: f64) -> vectorcraft_geom::Vec2 {
|
||||
vectorcraft_geom::Vec2::new(angle.cos(), angle.sin())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_character_positions_rotation_and_shifts() {
|
||||
let d = open(r#"<text x="10 30 60" y="50" rotate="15 -30" dy="0 -4" font-size="12">abcd</text>"#);
|
||||
|
||||
@@ -1663,6 +1663,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623).
|
||||
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
|
||||
SVG import places right-to-left point type and type on a path as [SVG 2's `text-anchor`](https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties) says: with `direction: rtl`, `start` puts the right edge of the text at its position (on a path, the text ends at `startOffset`) and `end` puts the left edge there. Each line and each absolutely placed chunk is anchored by the `text-anchor` and `direction` of the element holding its first character, a `<tspan>`'s own included, and each line starts at its own `x`. An absolutely placed chunk is anchored by the left and right edges of its glyphs as they are drawn, so a chunk of several right-to-left characters, whose first character is drawn at its right, sits on its anchor too. A letter-spaced right-to-left line keeps the tracking of all its letters inside the advance it anchors (`rtl_point_text_start_and_end_use_directional_edges`, `rtl_type_on_a_path_starts_at_its_directional_edge`, `rtl_letter_spaced_lines_stay_on_their_anchor`, `rtl_lines_use_each_chunk_first_character_direction_and_anchor`, `each_line_starts_at_its_own_x`, `absolutely_placed_chunks_use_their_own_rtl_anchor` and `absolutely_placed_rtl_chunks_of_several_characters_anchor_by_their_visual_edges` in `crates/svg/tests/it/import_text.rs`).
|
||||
SVG import places type on a path of one closed subpath as [SVG 2's `startOffset` attribute](https://www.w3.org/TR/SVG2/text.html#TextPathElementStartOffsetAttribute) describes: text anchored to start before the path's start point, or offset past its end, wraps around the path, so text with `text-anchor="end"` at `startOffset` 0 ends at the start point, and a negative `startOffset` counts back from the end. Text longer than the loop draws its first characters whatever its anchor. A path of several subpaths, and an open path, still start the text at the path's start or end (`text_on_a_closed_path_wraps_past_the_path_start` in `crates/svg/tests/it/import_text.rs`).
|
||||
SVG import reads the bidi embedding of a `<tspan>`, `<textPath>` or `<a>` inside a `<text>` as [SVG 1.1's bidi section](https://www.w3.org/TR/SVG11/text.html#RelationshipWithBi-directionality) describes: with `unicode-bidi: embed` its characters take its `direction` as their Character Direction, an element with `normal` stays in its parent's embedding, and the `<text>` keeps its own paragraph direction, so a run that SVG export writes with `direction` and `unicode-bidi="embed"` opens with its Character Direction again. Character Direction holds one level of embedding, so `bidi-override`, `isolate` and `isolate-override` are imported as embeddings in their `direction`, `plaintext` as an embedding in the direction of its first strong character, and an embedding inside another as an embedding of its own, each with an import warning. With `unicode-bidi: plaintext` on the `<text>`, each of its paragraphs runs, and each chunk is anchored, in the direction of its first strong character (`a_tspan_bidi_embedding_becomes_its_character_direction`, `unicode_bidi_is_not_inherited_and_reads_from_the_cascade` and `unicode_bidi_values_the_text_model_cannot_hold_are_reported` in `crates/svg/tests/it/import_text.rs`; `character_direction_reads_back` in `crates/svg/tests/it/text_layout.rs`).
|
||||
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
|
||||
SVG export (Save As, Export As, Export for Screens, Asset Export and the clipboard share the writer) writes simple shapes as SVG basic shapes, as the reference app does (an ellipse is an `ellipse` tag until an anchor is added, [forum](https://community.adobe.com/t5/illustrator-discussions/trouble-exporting-shape-with-points-to-svg/td-p/10720325); rectangles come out as `<rect>` and `<polygon>`, [forum](https://community.adobe.com/t5/illustrator-discussions/force-svg-export-using-all-lt-rect-gt/td-p/11282378)): an axis-aligned rectangle as `<rect>`, a circle or ellipse drawn with four standard arcs as `<circle>`/`<ellipse>`, a straight line as `<line>`, and other straight-segment paths as `<polygon>`/`<polyline>`, at the written precision (#4652). A rectangle or ellipse with a dashed stroke, whose dashes would start elsewhere, a turned ellipse, curves and compound paths stay `<path>`. `crates/svg/tests/it/basic_shapes.rs` checks each element, that the picture renders as the canvas does and that the geometry reimports.
|
||||
|
||||
Reference in New Issue
Block a user