PDF export: the hyphen that ends a hyphenated line is written as text

With type written as text, PDF export drew the hyphen that ends a line
broken inside a word as a path. The file's text had no hyphen where the
canvas draws one, so copied or searched text read "considerations"
where the line shows "con-".

The hyphen is now written as text where the line draws it, as the
character the font gives its glyph to: "-", or U+2010 when the font
gives the glyph to U+2010 and not to "-". It is a text run of its own,
and right-to-left lines keep their /ReversedChars marking. The invisible
text of type without paint holds the hyphens too. Kashidas and list
markers are still drawn as paths.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-24 19:02:08 +07:00
committed by bflatastic
parent 8c3672fb31
commit bef464ea97
4 changed files with 207 additions and 8 deletions
+38 -7
View File
@@ -1767,17 +1767,26 @@ impl Exporter<'_> {
/// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame
/// (size, scale, rotation) and a baseline are written together. Colour glyphs (bitmap emoji,
/// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the
/// indices of those it can't write or draw (SVG glyphs) are returned with the outlines.
/// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. The
/// hyphen the layout adds where it breaks a line inside a word ([`break_hyphen`]) is written
/// as the character its glyph stands for, on its own after the glyphs before it.
fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint, images: bool) -> (BezPath, Vec<usize>) {
let (mut rest, mut unwritten) = (BezPath::new(), vec![]);
let mut line: Option<GlyphLine> = None;
// The characters the glyphs stand for: the plain text, then, when the run has glyphs that
// stand for no character of it, the characters a line-break hyphen can stand for.
let written: Cow<str> =
if layout.glyphs.iter().any(|g| g.run == run && g.len == 0) { Cow::Owned(format!("{text}{BREAK_HYPHENS}")) } else { Cow::Borrowed(text) };
for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) {
let coloured = g.image.is_some() || g.colour.is_some();
if coloured && !images {
continue;
}
let range = g.byte..g.byte + g.len;
let ch = text.get(range.clone()).and_then(|t| t.chars().next());
// A list marker's glyphs stand for no character of the text either; they are not
// line-break hyphens, whatever the marker.
let hyphen = if layout.marker_of(gi).is_some() { None } else { break_hyphen(g) };
let range = hyphen.clone().map_or(g.byte..g.byte + g.len, |h| text.len() + h.start..text.len() + h.end);
let ch = written.get(range.clone()).and_then(|t| t.chars().next());
// Spaces are written too (text extraction needs them); control characters and soft
// hyphens draw nothing.
let blank = g.outline.elements().is_empty() && !coloured;
@@ -1808,8 +1817,9 @@ impl Exporter<'_> {
}
let size = upem * sy;
let frame = g.xf * Affine::scale(1.0 / sy);
// Its pen position along the frame's baseline, when it continues the line.
let x = line.as_ref().filter(|l| l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| {
// Its pen position along the frame's baseline, when it continues the line (a
// line-break hyphen never does).
let x = line.as_ref().filter(|l| hyphen.is_none() && l.face == g.font_id && (l.size - size).abs() < 1e-6).and_then(|l| {
let [a, b, c, d, e, f] = (l.frame.inverse() * frame).as_coeffs();
let same = (a - 1.0).abs() < 1e-6 && b.abs() < 1e-6 && c.abs() < 1e-6 && (d - 1.0).abs() < 1e-6 && f.abs() < 1e-4;
same.then_some(e)
@@ -1818,7 +1828,7 @@ impl Exporter<'_> {
Some(x) => x,
None => {
if let Some(l) = line.take() {
self.draw_glyph_line(s, l, text, fill, layout.bounds);
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
}
line = Some(GlyphLine { face: g.font_id, font, size, frame, glyphs: vec![] });
0.0
@@ -1829,9 +1839,15 @@ impl Exporter<'_> {
if let Some(l) = line.as_mut() {
l.glyphs.push((g.gid, x, advance, range));
}
// A line-break hyphen is written on its own.
if hyphen.is_some()
&& let Some(l) = line.take()
{
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
}
}
if let Some(l) = line {
self.draw_glyph_line(s, l, text, fill, layout.bounds);
self.draw_glyph_line(s, l, &written, fill, layout.bounds);
}
(rest, unwritten)
}
@@ -2065,6 +2081,21 @@ fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool {
vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid)
}
/// The characters a line-break hyphen can stand for, as real text: '-' and U+2010 (HYPHEN).
const BREAK_HYPHENS: &str = "-\u{2010}";
/// The bytes of [`BREAK_HYPHENS`] that glyph `g` stands for when it is a hyphen the layout adds
/// where it breaks a line inside a word: a glyph that stands for no character of the text (length
/// 0) and that its face's character map gives to '-' or, failing that, to U+2010. `None` for any
/// other glyph, kashida tatweels included.
fn break_hyphen(g: &vectorcraft_text::PositionedGlyph) -> Option<std::ops::Range<usize>> {
if g.len != 0 || g.gid == 0 {
return None;
}
let face = vectorcraft_text::FontDb::global().face_by_id(g.font_id)?;
BREAK_HYPHENS.char_indices().find(|&(_, c)| face.glyph_for(c) == g.gid).map(|(i, c)| i..i + c.len_utf8())
}
#[cfg(test)]
mod tests {
use super::*;
+165
View File
@@ -97,6 +97,171 @@ fn type_copied_by_a_transform_effect_is_written_as_text() {
}
}
/// Area type 120 points wide holding `text` in `style`, hyphenated (Paragraph panel › Hyphenate)
/// when `hyphenate` is set.
fn hyphenated(text: &str, style: CharStyle, hyphenate: bool) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style);
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 140.0, 280.0)) };
t.xf = Affine::IDENTITY;
t.para.hyphenate = hyphenate;
t
}
/// The characters the canvas draws for the type objects of `d`, in layout order (a hyphen for
/// each glyph that stands for no character), and the origin of each such glyph in document space.
fn drawn_with_hyphens(d: &Document) -> (String, Vec<Point>) {
let (mut drawn, mut hyphens) = (String::new(), vec![]);
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let text = t.plain_text();
for g in &vectorcraft_text::layout(FontDb::global(), t).glyphs {
if g.len == 0 {
drawn.push('-');
hyphens.push(t.xf * g.origin);
} else if let Some(c) = text.get(g.byte..).and_then(|s| s.chars().next()).filter(|c| !c.is_control() && *c != '\u{AD}') {
drawn.push(c);
}
}
}
});
(drawn, hyphens)
}
/// The origins in document space of the glyphs of the type objects of `d` that stand for '-'.
fn hyphen_origins(d: &Document) -> Vec<Point> {
let mut out = vec![];
d.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
let text = t.plain_text();
let lay = vectorcraft_text::layout(FontDb::global(), t);
out.extend(lay.glyphs.iter().filter(|g| g.len > 0 && text.get(g.byte..).is_some_and(|s| s.starts_with('-'))).map(|g| t.xf * g.origin));
}
});
out
}
/// The hyphen at the end of a line that area type breaks inside a word (Hyphenate) is written as
/// text: reopened, the export reads each line's characters and then the hyphen where the canvas
/// draws one, the hyphen sits where the canvas draws it, and the type's ink bounds match those of
/// outlined type. Type without paint, written as invisible text, holds the hyphens too.
#[test]
fn line_break_hyphens_are_written_as_text_where_the_canvas_draws_them() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
for paint in [Paint::solid(Color::BLACK), Paint::None] {
let d = doc(vec![hyphenated(text, CharStyle { fill: paint.clone(), ..style(14.0) }, true)]);
let (drawn, hyphens) = drawn_with_hyphens(&d);
assert!(!hyphens.is_empty(), "the layout hyphenates: {drawn}");
let back = import_as(&pdf(&d, false).bytes, TextAs::Text);
let all = texts(&back).concat();
let solid = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
assert_eq!(solid(&all), solid(&drawn), "{paint:?}");
// Each hyphen follows its line's last character, with no space between them.
assert!(!all.chars().zip(all.chars().skip(1)).any(|(a, b)| a.is_whitespace() && b == '-'), "{paint:?}: {all:?}");
let got = hyphen_origins(&back);
for p in &hyphens {
assert!(got.iter().any(|g| g.distance(*p) < 0.1), "{paint:?}: a hyphen at {p:?}: {got:?}");
}
}
let d = doc(vec![hyphenated(text, style(14.0), true)]);
let (real, outlined) = (ink(&import_as(&pdf(&d, false).bytes, TextAs::Outlines)), ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)));
let off = [real.x0 - outlined.x0, real.y0 - outlined.y0, real.x1 - outlined.x1, real.y1 - outlined.y1].iter().fold(0.0f64, |m, v| m.max(v.abs()));
assert!(off < 0.5, "{real:?} vs {outlined:?}");
}
/// Right-to-left area type broken at its soft hyphens writes each hyphen as text where the canvas
/// draws it, and its letters come back in their order. Skipped without a font that covers Hebrew.
#[test]
fn line_break_hyphens_of_right_to_left_lines_are_written_as_text_where_the_canvas_draws_them() {
let db = FontDb::global();
let Some(face) =
["Arial", "Noto Sans Hebrew", "DejaVu Sans", "Liberation Sans"].into_iter().filter_map(|f| db.face(f, "Regular")).find(|f| f.covers('ש'))
else {
return;
};
let text = "שלוםעולם\u{AD}גדולמאוד\u{AD}ומאוד ירושלים\u{AD}שלזהב\u{AD}ובתלאביב";
let mut t = hyphenated(text, CharStyle { font_family: face.family.clone(), ..style(14.0) }, true);
t.para.justify = vectorcraft_doc::Justify::Auto;
let lines = vectorcraft_text::layout(db, &t).lines.len();
let d = doc(vec![t]);
let (_, hyphens) = drawn_with_hyphens(&d);
assert!(!hyphens.is_empty(), "the lines break at soft hyphens");
let r = pdf(&d, false);
// Each line's letters are still marked as written in reverse (/ReversedChars), the hyphenated
// lines too.
let reversed = String::from_utf8_lossy(&r.bytes).matches("/ReversedChars").count();
assert!(reversed >= lines, "{reversed} reversed runs for {lines} lines");
let back = import_as(&r.bytes, TextAs::Text);
let got = hyphen_origins(&back);
for p in &hyphens {
assert!(got.iter().any(|g| g.distance(*p) < 0.1), "a hyphen at {p:?}: {got:?}");
}
let letters = |s: &str| s.chars().filter(|c| c.is_alphabetic()).collect::<String>();
assert_eq!(letters(&texts(&back).concat()), letters(text));
}
/// The kashidas of justified Arabic type (Insert Kashidas), which stand for no character either,
/// are drawn as paths: reopened, the type holds its letters and no hyphen or tatweel. Skipped
/// without an OpenType font that covers Arabic and the tatweel, or when the layout adds no kashida.
#[test]
fn kashidas_are_not_written_as_hyphens() {
let db = FontDb::global();
let Some(face) = ["Noto Sans Arabic", "Arial", "Geeza Pro"]
.into_iter()
.filter_map(|f| db.face(f, "Regular"))
.find(|f| f.covers('ب') && f.covers('د') && f.covers('\u{0640}'))
else {
return;
};
let text = "كتب ورد محمد";
let mut t = TextObject::point(Point::ZERO, text, CharStyle { font_family: face.family.clone(), ..style(20.0) });
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(20.0, 20.0, 320.0, 280.0)) };
t.xf = Affine::IDENTITY;
t.para.justify = vectorcraft_doc::Justify::JustifyAll;
t.para.kashida = vectorcraft_doc::text::Kashida::Medium;
let tatweel = face.glyph_for('\u{0640}');
if !vectorcraft_text::layout(db, &t).glyphs.iter().any(|g| g.len == 0 && g.gid == tatweel) {
return;
}
let back = texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text));
assert!(!back.concat().contains(['-', '\u{2010}', '\u{0640}']), "{back:?}");
// Every letter comes back, at least as often as the text holds it (the justified words may
// come back as type objects of their own).
let all = back.concat();
let count = |s: &str, c: char| s.chars().filter(|&x| x == c).count();
for c in text.chars().filter(|c| c.is_alphabetic()) {
assert!(count(&all, c) >= count(text, c), "{c}: {back:?}");
}
}
/// Area type without Hyphenate comes back with its characters only, and no hyphen.
#[test]
fn type_without_line_break_hyphens_comes_back_without_hyphens() {
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
let d = doc(vec![hyphenated(text, style(14.0), false)]);
let (drawn, hyphens) = drawn_with_hyphens(&d);
assert!(hyphens.is_empty(), "{drawn}");
let all = texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)).concat();
assert_eq!(all.split_whitespace().collect::<String>(), text.split_whitespace().collect::<String>());
}
/// The marker of a hyphenated bulleted list, which stands for no character of the text either, is
/// not written as a hyphen: reopened, the type holds one hyphen for each line-break hyphen only.
#[test]
fn list_markers_are_not_written_as_line_break_hyphens() {
use vectorcraft_doc::text::{Bullet, ParaList};
let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
for bullet in [Bullet::Dash, Bullet::Dot] {
let mut t = hyphenated(text, style(14.0), true);
t.para.list = Some(ParaList::bulleted(bullet));
let lay = vectorcraft_text::layout(FontDb::global(), &t);
assert!(!lay.markers.is_empty(), "{bullet:?}: the paragraph has a marker");
let breaks = lay.glyphs.iter().enumerate().filter(|&(i, g)| g.len == 0 && lay.marker_of(i).is_none()).count();
assert!(breaks > 0, "{bullet:?}: the layout hyphenates");
let all = texts(&import_as(&pdf(&doc(vec![t]), false).bytes, TextAs::Text)).concat();
assert_eq!(all.matches(['-', '\u{2010}']).count(), breaks, "{bullet:?}: {all:?}");
}
}
/// A curve type runs along.
fn arc() -> PathData {
let mut p = BezPath::new();