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::*;