mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
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:
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user