mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -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();
|
||||
|
||||
+3
-1
@@ -2480,7 +2480,9 @@ always tagged, whatever `profiles` says). `outputIntent` embeds a profile as the
|
||||
`/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped`
|
||||
(PDF/A files keep their own output intent). Type is written as selectable, searchable text in embedded subset fonts
|
||||
with a ToUnicode map (fonts whose licence forbids embedding stay outlines, with a warning); `advanced: {outlineText:
|
||||
true}` writes every glyph as an outline instead. Type that Transform effects only move and copy (no scale, rotation,
|
||||
true}` writes every glyph as an outline instead. The hyphen that ends a line broken inside a word (`hyphenate`, or a
|
||||
soft hyphen) is written as text where the line draws it: the character `-`, or U+2010 when the font gives the hyphen's
|
||||
glyph to U+2010 and not to `-`. Kashidas are drawn as paths. Type that Transform effects only move and copy (no scale, rotation,
|
||||
reflection or Random) is written as text once in each place when its characters are in solid colors without underline
|
||||
or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its
|
||||
transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type
|
||||
|
||||
@@ -773,6 +773,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests_hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.
|
||||
Optical Margin Alignment and Roman Hanging Punctuation hang right-to-left paragraphs of point and area type, and type on a path whose first paragraph runs right to left, too. Auto aligns a right-to-left line at its right, or at the end bracket of a path, so it hangs there. A right-to-left line is read from right to left: where it hangs at its left margin, `."` ending it hangs both glyphs.
|
||||
The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.
|
||||
PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths.
|
||||
The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting.
|
||||
The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. Numbered lists, the Bullets and Numbering dialog, levels, list detection while typing and Convert to Text are not built yet.
|
||||
The Character panel menu's Character Direction (with the Indic options; `text.setFormat {charDirection}`, `text.setRangeStyle {charDirection}`) sets the selected characters left to right or right to left inside their paragraph, as a Unicode bidi embedding: a date set Left to Right keeps its order in right-to-left text, and a word set Right to Left keeps the punctuation after it on its left. Default (the default) leaves the characters to the bidi algorithm; character styles and the native file keep the setting.
|
||||
|
||||
Reference in New Issue
Block a user