From fcaeaa74a7e8eeaddc7fdbb955582506a56c8341 Mon Sep 17 00:00:00 2001 From: imachus <51533227+imachus@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:44:40 +0900 Subject: [PATCH] PDF/.ai import: Japanese text as live type (CID fonts, vertical columns, missing fonts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Japanese PDFs and PDF-compatible .ai files often set text in CID fonts with no ToUnicode map, in vertical columns, and in fonts the reader doesn't have. They came in as outlines (or as the wrong characters). - CID fonts without ToUnicode: the character comes from the font's CFF charset through the standard CMaps (hayro's `embed-cmaps`); Kangxi radicals that some generators map to are unified with their ideographs (⿓ → 龍). - A glyph becomes live type only when the installed font draws the same glyph: compared by box with the font of the PDF's PostScript name, or the closest style of its family (a variable font's named instances), whose style the type then takes. Otherwise it keeps its outline. - Vertical columns (WMode 1, glyphs with no horizontal advance) come back as vertical type, letter spacing as tracking; a single upright glyph is vertical type too. Horizontal lines get tracking from the glyph positions the same way. - Text in a font that isn't installed keeps its outlines (what prints), with an editable copy in a substitute font, drawn pink, on a non-printing layer named after the missing fonts (stroke-only glyphs included). - Marked content other than a layer's (spans with actual text, artifacts) no longer ends a line of type: 篳篥・龍笛・笙 came back as three objects when one character was in a span. - Tests in tests_live_text.rs and tests_realtext.rs. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 38 ++++ ROADMAP.md | 2 +- crates/pdf/Cargo.toml | 3 +- crates/pdf/src/import.rs | 277 +++++++++++++++++++++++++--- crates/pdf/src/import_text.rs | 97 +++++++++- crates/pdf/src/lib.rs | 2 + crates/pdf/src/tests_live_text.rs | 296 ++++++++++++++++++++++++++++++ crates/pdf/src/tests_realtext.rs | 17 ++ 8 files changed, 704 insertions(+), 28 deletions(-) create mode 100644 crates/pdf/src/tests_live_text.rs diff --git a/Cargo.lock b/Cargo.lock index afcd8a54..5026a8cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,6 +145,21 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -509,6 +524,27 @@ dependencies = [ "piper", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -1650,6 +1686,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" dependencies = [ + "brotli", "hayro-postscript", ] @@ -4451,6 +4488,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "skrifa 0.47.0", "smallvec", "thiserror 2.0.21", "vectorcraft-brush", diff --git a/ROADMAP.md b/ROADMAP.md index d329c038..ec858475 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,7 +134,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. -- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. +- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed keeps its outlines with a pink editable copy on a non-printing layer. - **Files (M4):** - `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash, background save and export. - SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units; PDF and PDF-compatible `.ai` in and out (layers, masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 6031b5b6..4b1bb566 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -15,13 +15,14 @@ vectorcraft-effects = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-text = { workspace = true } +skrifa = { workspace = true } kurbo = { workspace = true } thiserror = { workspace = true } flate2 = { workspace = true } image = { workspace = true } krilla = { workspace = true } hayro-syntax = "0.7.2" -hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } +hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } log = { workspace = true } smallvec = "1" serde = { workspace = true } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index cf464018..171d639f 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -23,7 +23,7 @@ use crate::import_color::{Colors, Native}; use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec}; use crate::import_scan::{Ocgs, Scan, all_on, hides_forms, scan_page}; use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, mesh_shading, shading_gradient}; -use crate::import_text::{Families, Look, Placement, TextLine}; +use crate::import_text::{Families, Look, Placement, TextLine, Upright}; use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs}; /// The name of the paths text imports as. @@ -112,6 +112,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result Result Result Result, + table: hayro_interpret::hayro_cmap::CMap, +} + +impl CidText { + fn of(data: &[u8]) -> Option { + // An embedded CFF of a few CJK glyphs is kilobytes; whole fonts are some megabytes. + if data.len() > 64 << 20 { + return None; + } + use hayro_interpret::hayro_cmap::{CMap, CMapName, load_embedded}; + use skrifa::raw::ps::cff::{CffFontRef, dict, v1::Cff}; + use skrifa::raw::{FontData, FontRead}; + let cff = Cff::read(FontData::new(data)).ok()?; + let top = cff.top_dicts().get(0)?; + let ordering = dict::entries(top, None).filter_map(Result::ok).find_map(|e| match e { + dict::Entry::Ros { ordering, .. } => Some(ordering), + _ => None, + })?; + let name = match cff.string(ordering)? { + b"Japan1" => CMapName::AdobeJapan1Ucs2, + b"GB1" => CMapName::AdobeGb1Ucs2, + b"CNS1" => CMapName::AdobeCns1Ucs2, + b"Korea1" => CMapName::AdobeKorea1Ucs2, + _ => return None, + }; + let table = CMap::parse(load_embedded(name)?, load_embedded)?; + let charset = CffFontRef::new(data, 0, None).ok()?.charset()?; + let cids = (0..charset.num_glyphs().min(65_536)).map(|g| charset.string_id(skrifa::GlyphId::new(g)).map_or(0, |s| s.to_u16())).collect(); + Some(Self { cids, table }) + } + + fn unicode(&self, glyph: u32) -> Option { + let cid = *self.cids.get(glyph as usize)?; + self.table.lookup_bf_string(u32::from(cid)) + } +} + +/// The ideographs of the Kangxi radicals U+2F00–U+2FD5 (their NFKC forms): the CID → Unicode +/// tables of some PDFs map 龍 to the radical ⿓, which looks the same but isn't the character. +const KANGXI: &str = "一丨丶丿乙亅二亠人儿入八冂冖冫几凵刀力勹匕匚匸十卜卩厂厶又口囗土士夂夊夕大女子宀寸小尢尸屮山巛工己巾干幺广廴廾弋弓彐彡彳心戈戶手支攴文斗斤方无日曰月木欠止歹殳毋比毛氏气水火爪父爻爿片牙牛犬玄玉瓜瓦甘生用田疋疒癶白皮皿目矛矢石示禸禾穴立竹米糸缶网羊羽老而耒耳聿肉臣自至臼舌舛舟艮色艸虍虫血行衣襾見角言谷豆豕豸貝赤走足身車辛辰辵邑酉釆里金長門阜隶隹雨靑非面革韋韭音頁風飛食首香馬骨高髟鬥鬯鬲鬼魚鳥鹵鹿麥麻黃黍黑黹黽鼎鼓鼠鼻齊齒龍龜龠"; + +/// Is the PDF's glyph `o` for `text` the installed `face`'s glyph for it? The outlines' bounds must +/// agree within a tenth of an em: a different font under the same PostScript name, or a wrong +/// ToUnicode entry, would open as other letters. Ligatures (several characters) aren't checked, +/// nor CJK punctuation set vertically (its vertical form differs from the installed glyph). +/// How far glyph `o` (the PDF's, for `text`) is from the installed `face`'s glyph for it: the +/// largest difference between their boxes, in thousandths of an em. `None` when they are clearly +/// different glyphs (or the face lacks the character); `Some(0)` when there is nothing to compare +/// (several characters, a vertical form, no ink in either). +fn glyph_deviation(face: &vectorcraft_text::FontFace, text: &str, o: &hayro_interpret::font::OutlineGlyph, vertical: bool) -> Option { + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return Some(0.0) }; + if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) { + return Some(0.0); + } + let gid = face.glyph_for(c); + if gid == 0 { + return None; + } + let embedded = o.outline(); + let installed = vectorcraft_text::FontDb::global().outline(face, gid); + match (embedded.elements().is_empty(), installed.elements().is_empty()) { + (true, true) => Some(0.0), + (false, false) => { + let e = embedded.bounding_box(); + let k = 1000.0 / face.units_per_em(); + // Installed outlines are y-down; the PDF's glyph space is y-up. + let i = installed.bounding_box(); + let i = Rect::new(i.x0 * k, -i.y1 * k, i.x1 * k, -i.y0 * k); + let off = [(e.x0, i.x0), (e.x1, i.x1), (e.y0, i.y0), (e.y1, i.y1)].iter().fold(0.0f64, |m, (a, b)| m.max((a - b).abs())); + (off <= 100.0).then_some(off) + } + _ => None, + } +} + +/// The face of `face`'s family that draws glyph `o` (for `text`): `face` itself, or the style whose +/// glyph is closest (a variable font's named instances, or a family whose PostScript names don't +/// tell its styles apart, draw a character differently in each style). `None`: no style of the +/// family has that glyph, so the font isn't the installed one. +fn matching_face( + face: &Arc, + text: &str, + o: &hayro_interpret::font::OutlineGlyph, + vertical: bool, +) -> Option> { + let own = glyph_deviation(face, text, o, vertical); + if own == Some(0.0) { + return Some(face.clone()); + } + let db = vectorcraft_text::FontDb::global(); + let others = + db.styles(&face.family).into_iter().filter(|s| !s.eq_ignore_ascii_case(&face.style)).take(64).filter_map(|s| db.face(&face.family, &s)); + let mut best = own.map(|d| (d, face.clone())); + for f in others { + if let Some(d) = glyph_deviation(&f, text, o, vertical) + && best.as_ref().is_none_or(|(b, _)| d < *b) + { + best = Some((d, f)); + } + } + best.map(|(_, f)| f) +} + +pub(crate) fn unify_radical(c: char) -> char { + let i = (c as u32).wrapping_sub(0x2F00); + if i < 214 { KANGXI.chars().nth(i as usize).unwrap_or(c) } else { c } +} + +/// The font a PDF font draws with ([`Builder::font_name`]). +#[derive(Clone)] +struct FontInfo { + family: String, + style: String, + /// Available (installed or loaded). + found: bool, + /// The installed face of its PostScript name. + face: Option>, +} + /// Is this art text and nothing else (in groups and clips), with some text? fn only_text(nodes: &[Arc]) -> bool { fn walk(nodes: &[Arc], text: &mut bool) -> bool { @@ -311,9 +453,17 @@ struct Builder<'p> { warnings: Vec, /// Fonts by cache key → base font name (from [`scan_page`]). fonts: HashMap, - font_names: HashMap, + font_names: HashMap, families: Option, missing_fonts: Vec, + /// Font (cache key) → its characters by glyph, for CID-keyed fonts embedded without a + /// ToUnicode map. + cid_text: HashMap>>, + /// A line in a font that isn't installed: its glyphs keep their outlines, and this editable + /// copy goes to the page's non-printing layer ([`COPIES_LAYER`]). + copy_line: Option, + /// The page's editable copies. + copies: Vec>, /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. mask: Option>, masked: Vec>, @@ -427,6 +577,9 @@ impl<'p> Builder<'p> { font_names: HashMap::new(), families: None, missing_fonts: vec![], + cid_text: HashMap::new(), + copy_line: None, + copies: vec![], mask: None, masked: vec![], masks: HashMap::new(), @@ -479,6 +632,8 @@ impl<'p> Builder<'p> { self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; + self.copy_line = None; + self.copies.clear(); self.mask = None; self.masked.clear(); self.marked.clear(); @@ -503,6 +658,7 @@ impl<'p> Builder<'p> { /// Finish the open runs and frames → the root frame's children. fn close_frames(&mut self) -> Vec> { self.flush(); + self.flush_copy(); while self.stack.len() > 1 { self.pop_frame(); } @@ -844,26 +1000,39 @@ impl<'p> Builder<'p> { self.push_node(content); } - /// The family and style of font `key` (drawing glyph `o`). - fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> (String, String) { + /// The family and style of font `key` (drawing glyph `o`): the installed face of its PostScript + /// name, else the available family its name reads as; whether it is available. + fn font_name(&mut self, key: u128, o: &hayro_interpret::font::OutlineGlyph) -> FontInfo { if let Some(n) = self.font_names.get(&key) { return n.clone(); } let data = o.font_data(); let name = self.fonts.get(&key).cloned().or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); - let families = self.families.get_or_insert_with(Families::available); - let n = match name { - Some(name) => { + // A subset's six-letter tag. + let ps = name.as_deref().map(|n| match n.split_once('+') { + Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest.to_string(), + _ => n.to_string(), + }); + let installed = ps.as_deref().and_then(|ps| vectorcraft_text::FontDb::global().find_postscript(ps)); + let n = match (installed, name) { + (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), found: true, face: Some(face) }, + (None, Some(name)) => { + let families = self.families.get_or_insert_with(Families::available); let (family, style, found) = families.resolve(&name, weight, italic); + // Not installed: named by its PostScript name, which finds the font once it is. + let (family, style) = match (&ps, found) { + (Some(ps), false) if !ps.is_empty() => (ps.clone(), "Regular".to_string()), + _ => (family, style), + }; if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { self.missing_fonts.push(family.clone()); } - (family, style) + FontInfo { family, style, found, face: None } } - None => { + (None, None) => { let d = vectorcraft_doc::CharStyle::default(); - (d.font_family, d.font_style) + FontInfo { family: d.font_family, style: d.font_style, found: true, face: None } } }; self.font_names.insert(key, n.clone()); @@ -883,36 +1052,91 @@ impl<'p> Builder<'p> { if self.text_as != TextAs::Text || self.nested > 0 { return false; } - let text = match o.as_unicode() { + let key = o.font_cache_key(); + let unicode = o.as_unicode().or_else(|| { + let cid = self.cid_text.entry(key).or_insert_with(|| o.font_data().and_then(|d| CidText::of(d.data.as_ref().as_ref())).map(Arc::new)); + cid.as_ref().and_then(|c| c.unicode(o.glyph_id().to_u32())) + }); + let text: String = match unicode { Some(BfString::Char(c)) => c.to_string(), Some(BfString::String(s)) => s, None => return false, }; + let text: String = text.chars().map(unify_radical).collect(); let Some(at) = Placement::of(m) else { return false }; if text.is_empty() || text.chars().any(|c| c.is_control()) { return false; } - self.flush_glyphs(); - let font = o.font_cache_key(); + let width = o.advance_width().filter(|w| w.is_finite()); + // Set vertically (WMode 1): no horizontal advance; its em box is a full em wide. + let upright = width.is_some_and(|w| w.abs() <= 1.0); + let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0); + let mut info = self.font_name(key, o); + if let Some(face) = &info.face { + let Some(f) = matching_face(face, &text, o, upright) else { + self.warn("text whose glyphs differ from the installed font of the same name was kept as outlines"); + return false; + }; + info = FontInfo { family: f.family.clone(), style: f.style.clone(), found: true, face: Some(f) }; + } let (paint, opacity) = self.paint(paint, stroke.is_some()); let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale)); + let look = Look { + font: key, + family: info.family, + style: info.style, + size: at.size, + h_scale: at.h_scale, + fill: stroke.is_none().then_some(paint), + stroke, + }; + let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + let place = |line: &mut TextLine| { + if upright { line.push_upright(&look, at, opacity, Upright { top }, &text) } else { line.push(&look, at, opacity, advance, &text) } + }; + if !info.found { + // Not installed: the outlines are what prints; an editable copy goes over them on a + // non-printing layer, one per glyph: a fill-and-stroke glyph's stroke pass adds nothing + // to it, a stroke-only glyph gets one too. + if look.fill.is_none() + && let (Some(s), Some(line)) = (&look.stroke, &mut self.copy_line) + && line.stroke_last(key, at, s.clone()) + { + return false; + } + if !self.copy_line.as_mut().is_some_and(place) { + self.flush_copy(); + let mut line = TextLine::new(at, opacity); + place(&mut line); + self.copy_line = Some(line); + } + return false; + } + self.flush_glyphs(); // A stroke over the glyph just filled (fill and stroke rendering). - if let (Some(s), Some(line)) = (&stroke, &mut self.text) - && line.stroke_last(font, at, s.clone()) + if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_last(key, at, s.clone()) { return true; } - let (family, style) = self.font_name(font, o); - let look = Look { font, family, style, size: at.size, h_scale: at.h_scale, fill: stroke.is_none().then_some(paint), stroke }; - let advance = o.advance_width().filter(|w| w.is_finite()).map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); - if !self.text.as_mut().is_some_and(|l| l.push(&look, at, opacity, advance, &text)) { + if !self.text.as_mut().is_some_and(place) { self.flush_text(); let mut line = TextLine::new(at, opacity); - line.push(&look, at, opacity, advance, &text); + place(&mut line); self.text = Some(line); } true } + + /// The editable copy of a line in a missing font, in pink, to the page's copies. + fn flush_copy(&mut self) { + let Some(mut line) = self.copy_line.take() else { return }; + line.recolor(&Paint::solid(Color::rgb(1.0, 0.2, 0.6))); + let Some((t, _)) = line.finish() else { return }; + let n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + self.copies.push(Arc::new(n)); + } } fn rgba_png(rgba: Vec, w: u32, h: u32) -> Option> { @@ -1196,7 +1420,6 @@ impl<'a> Device<'a> for Builder<'_> { if self.nested > 0 { return; } - self.flush(); let at = self.tag_at; self.tag_at += 1; let group = match self.scan.tags.get(at) { @@ -1210,6 +1433,11 @@ impl<'a> Device<'a> for Builder<'_> { } } .filter(|_| self.route); + // Only a layer's marked content ends the runs gathered so far: other tags (a span with its + // actual text, an artifact) leave a line of type whole. + if group.is_some() { + self.flush(); + } if let Some(g) = group && self.stack.len() > 1 && let Some(f) = self.stack.last_mut() @@ -1223,7 +1451,8 @@ impl<'a> Device<'a> for Builder<'_> { if self.nested > 0 { return; } - self.flush(); - self.marked.pop(); + if self.marked.pop().flatten().is_some() { + self.flush(); + } } } diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 5b0110ce..9d2f816c 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -48,6 +48,13 @@ impl Placement { } } +/// A glyph set vertically (WMode 1: no horizontal advance) and the top centre of its em box: a +/// column of them is vertical type. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Upright { + pub top: Point, +} + /// What a run of type is drawn with: its font (cache key, family and style), size, horizontal /// scale, fill and stroke (paint and width). #[derive(Clone, Debug, PartialEq)] @@ -101,6 +108,10 @@ pub(crate) struct TextLine { /// Each glyph's baseline origin, then where the last one ends. baseline: Vec, runs: Vec<(Look, String)>, + /// The first glyph was set vertically ([`Upright`]). + upright: Option, + /// The glyphs make a column (vertical type), each below the last. + column: bool, } /// The most a glyph on a curve turns from the one before it (about 20°). @@ -108,12 +119,58 @@ const CURVE_TURN_COS: f64 = 0.94; impl TextLine { pub fn new(at: Placement, opacity: f32) -> Self { - Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![] } + Self { at, opacity, next: at.origin, last: at.origin, last_dir: at.dir, baseline: vec![], runs: vec![], upright: None, column: false } + } + + /// Add glyph `text` set vertically (`upright`) if it continues this column (or makes this + /// line's single glyph a column): below the last one, in the same column. `false`: it starts + /// another line. + pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool { + let down = -self.at.up(); + let size = self.at.size.max(at.size); + let Some((last, run)) = self.runs.last_mut() else { + self.runs.push((look.clone(), text.to_string())); + (self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin); + return true; + }; + let across = (at.origin - self.at.origin).dot(self.at.dir); + let step = (at.origin - self.next).dot(down); + // Only glyphs set vertically (WMode 1) come here, so a second one below the first, one em + // on or letter-spaced up to another em, makes a column. + let first_step = (at.origin - self.last).dot(down); + let starts_column = !self.column && self.upright.is_some() && first_step > size * 0.95 && first_step < size * 2.0; + let in_column = (self.column || starts_column) + && opacity == self.opacity + && at.dir.dot(self.at.dir) > 0.9995 + && across.abs() < size * 0.6 + && step > -size * 0.3 + && step < size * 1.2; + if !in_column { + return false; + } + self.column = true; + // Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left + // out (the tracking set in `finish` keeps narrower gaps). + if step > size * 0.9 && !run.ends_with(' ') && !text.starts_with(' ') { + run.push(' '); + } + if last.takes(look) { + run.push_str(text); + } else { + self.runs.push((look.clone(), text.to_string())); + } + self.next = at.origin + down * at.size; + self.last = at.origin; + true } /// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if /// it continues this line; `false`: it starts another. pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool { + // A vertical line (even a single upright glyph) takes no horizontal glyphs. + if self.column || self.upright.is_some() { + return false; + } if let Some((last, run)) = self.runs.last_mut() { let size = self.at.size.max(at.size); let gap = (at.origin - self.next).dot(self.last_dir); @@ -190,6 +247,14 @@ impl TextLine { true } + /// Paint every run's fill with `paint` (editable copies of text in missing fonts). + pub fn recolor(&mut self, paint: &Paint) { + for (look, _) in &mut self.runs { + look.fill = Some(paint.clone()); + look.stroke = None; + } + } + /// The point type object and its opacity. pub fn finish(mut self) -> Option<(TextObject, f32)> { // Turned along a curve: type on a path through the glyphs. @@ -205,11 +270,39 @@ impl TextLine { let first = runs.next()?; let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); - t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2()); + let angle = self.at.dir.atan2(); + t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle); + let db = vectorcraft_text::FontDb::global(); if let Some(path) = path { t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0 }; t.xf = Affine::IDENTITY; + t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds); + return Some((t, self.opacity)); } + let (start, along) = match self.upright { + // Vertical point type (a column, or a single upright glyph) is anchored at the top + // centre of its first em box. + Some(u) => { + t.vertical = true; + t.xf = Affine::translate(u.top.to_vec2()) * Affine::rotate(angle); + (self.at.origin, -self.at.up()) + } + _ => (self.at.origin, self.at.dir), + }; + // Tracking that makes the line as long as in the file: the PDF placed each glyph, the + // layout sets them by their advances. Spread over the gaps between characters (tracking + // follows each character, the last one's past the end of the line). + let length = (self.next - start).dot(along); + let natural: f64 = vectorcraft_text::layout(db, &t).glyphs.iter().map(|g| g.advance).sum(); + let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum(); + let size = self.at.size; + if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 { + let tracking = ((length - natural) / (chars - 1) as f64 / size * 1000.0).clamp(-1000.0, 1000.0).round(); + for r in &mut t.runs { + r.style.tracking = tracking; + } + } + t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds); Some((t, self.opacity)) } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 11b72369..fcf56b8d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -232,6 +232,8 @@ mod tests_import_options; #[cfg(test)] mod tests_layers; #[cfg(test)] +mod tests_live_text; +#[cfg(test)] mod tests_marks; #[cfg(test)] mod tests_output; diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs new file mode 100644 index 00000000..86652c0c --- /dev/null +++ b/crates/pdf/src/tests_live_text.rs @@ -0,0 +1,296 @@ +//! PDF text in installed fonts imports as live type (one point type object per line). + +use krilla::geom::{Point as KPoint, Transform}; +use krilla::page::PageSettings; +use krilla::text::{Font, TextDirection}; +use vectorcraft_doc::{Document, NodeKind, TextObject}; + +use crate::*; + +const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// A 300 × 200 pt page with `lines` of (x, y from the top, size, text) in Source Sans 3, and the +/// same rotated 90° when `rotated`. +fn text_pdf(lines: &[(f32, f32, f32, &str)], rotated: bool) -> Vec { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(SOURCE_SANS.into(), 0).unwrap(); + if rotated { + s.push_transform(&Transform::from_row(0.0, 1.0, -1.0, 0.0, 150.0, 20.0)); + } + for &(x, y, size, text) in lines { + s.draw_text(KPoint::from_xy(x, y), font.clone(), size, text, false, TextDirection::Auto); + } + if rotated { + s.pop(); + } + s.finish(); + page.finish(); + pdf.finish().unwrap() +} + +fn texts(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push((**t).clone()); + } + }); + v +} + +fn ink_width(t: &TextObject) -> f64 { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| g.advance).sum() +} + +#[test] +fn lines_of_text_in_installed_fonts_come_in_as_point_type() { + let bytes = text_pdf(&[(20.0, 50.0, 24.0, "Gagaku Concert"), (20.0, 90.0, 12.0, "Hall A")], false); + let r = import_with_report(&bytes, &ImportOptions::default()).unwrap(); + let t = texts(&r.document); + assert_eq!(t.len(), 2, "{t:?}"); + let first = t.iter().find(|t| t.plain_text() == "Gagaku Concert").unwrap(); + let st = first.first_style(); + assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Source Sans 3", "Regular")); + assert!((st.size - 24.0).abs() < 0.01); + let origin = first.xf * kurbo::Point::ZERO; + assert!((origin.x - 20.0).abs() < 0.01 && (origin.y - 50.0).abs() < 0.01, "on the PDF's baseline: {origin:?}"); + assert!(st.tracking.abs() <= 1.0, "set as VectorCraft sets it: {}", st.tracking); + assert!(t.iter().any(|t| t.plain_text() == "Hall A" && (t.first_style().size - 12.0).abs() < 0.01)); + assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings); +} + +#[test] +fn rotated_lines_keep_their_angle_and_outline_text_option_outlines_everything() { + let bytes = text_pdf(&[(0.0, 0.0, 18.0, "Sideways")], true); + let d = import_with_report(&bytes, &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1); + let dir = t[0].xf * kurbo::Point::new(1.0, 0.0) - t[0].xf * kurbo::Point::ZERO; + assert!(dir.x.abs() < 1e-6 && (dir.y - 1.0).abs() < 1e-6, "the baseline runs down the page: {dir:?}"); + let outlined = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap(); + assert!(texts(&outlined.document).is_empty()); + assert!(outlined.warnings.iter().any(|w| w.contains("outlines"))); +} + +#[test] +fn letter_spacing_in_the_pdf_becomes_tracking() { + // Source Sans 3 at 20 pt, every letter 2 pt further apart than its advance. + let mut x = 20.0; + let mut lines = vec![]; + let word = "Wide"; + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let mut pieces = vec![]; + for c in word.chars() { + pieces.push((x, c.to_string())); + x += font.advance(font.glyph_for(c)) as f32 / font.units_per_em() as f32 * 20.0 + 2.0; + } + for (x, c) in &pieces { + lines.push((*x, 60.0, 20.0, c.as_str())); + } + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "{t:?}"); + assert_eq!(t[0].plain_text(), "Wide"); + assert!((t[0].first_style().tracking - 100.0).abs() <= 1.0, "2 pt at 20 pt = 100/1000 em: {}", t[0].first_style().tracking); + let expected = (x - 2.0 - 20.0) as f64 + 2.0; + assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0])); +} + +#[test] +fn kangxi_radicals_from_cid_tables_become_ideographs() { + assert_eq!(crate::import::unify_radical('\u{2FD3}'), '龍'); + assert_eq!(crate::import::unify_radical('\u{2F00}'), '一'); + assert_eq!(crate::import::unify_radical('笙'), '笙'); +} + +#[test] +fn a_glyph_whose_unicode_names_another_letter_stays_outlined() { + // The glyph of `l` labelled `W` in the PDF's ToUnicode map: opening it as live `W` would change + // what the page shows. + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let l = font.glyph_for('l'); + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let kfont = Font::new(SOURCE_SANS.into(), 0).unwrap(); + let glyph = krilla::text::KrillaGlyph { + glyph_id: krilla::text::GlyphId::new(l), + text_range: 0..1, + x_advance: 0.25, + x_offset: 0.0, + y_offset: 0.0, + y_advance: 0.0, + location: None, + }; + s.draw_glyphs(KPoint::from_xy(20.0, 50.0), &[glyph], kfont, "W", 24.0, false); + s.finish(); + page.finish(); + let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap(); + assert!(texts(&r.document).is_empty(), "{:?}", texts(&r.document)); + assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings); +} + +/// Source Sans 3 renamed `SourceSans9-Regular` (a font no machine has installed). +fn uninstalled_font() -> Vec { + let mut bytes = SOURCE_SANS.to_vec(); + let utf16: Vec = "SourceSans3-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect(); + let renamed16: Vec = "SourceSans9-Regular".encode_utf16().flat_map(u16::to_be_bytes).collect(); + for (from, to) in [(b"SourceSans3-Regular".to_vec(), b"SourceSans9-Regular".to_vec()), (utf16, renamed16)] { + let mut i = 0; + while let Some(at) = bytes[i..].windows(from.len()).position(|w| w == from.as_slice()) { + bytes[i + at..i + at + from.len()].copy_from_slice(&to); + i += at + from.len(); + } + } + bytes +} + +#[test] +fn text_in_a_missing_font_is_outlined_with_a_pink_editable_copy_that_does_not_print() { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(uninstalled_font().into(), 0).unwrap(); + s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto); + s.finish(); + page.finish(); + let r = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap(); + let d = &r.document; + assert_eq!(d.layers.len(), 2, "the page and its copies"); + // The page keeps the outlines. + let page_layer = &d.layers[0]; + let mut outlines = 0; + page_layer.walk(&mut |n| outlines += usize::from(n.name.as_deref() == Some(""))); + assert!(outlines > 0); + // Over it, a non-printing layer of editable copies, named by the font's PostScript name. + let copies = &d.layers[1]; + let NodeKind::Layer { printable, .. } = &copies.kind else { panic!() }; + assert!(!printable && copies.name.as_deref().unwrap().starts_with(crate::import::COPIES_LAYER)); + let mut t = vec![]; + copies.walk(&mut |n| { + if let NodeKind::Text(x) = &n.kind { + t.push((**x).clone()); + } + }); + assert_eq!(t.len(), 1); + assert_eq!(t[0].plain_text(), "Gagaku"); + let st = t[0].first_style(); + assert_eq!(st.font_family, "SourceSans9-Regular"); + assert_eq!(st.fill, vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.2, 0.6))); + assert!(r.warnings.iter().any(|w| w.contains("SourceSans9-Regular") && w.contains("editable copies")), "{:?}", r.warnings); + // Exports leave the non-printing copies out (compared with the copies made printable). + let count = |d: &Document| { + let bytes = crate::export(d, &Default::default()).unwrap(); + let back = import_with_report(&bytes, &ImportOptions::default()).unwrap().document; + let mut n = 0; + back.walk(|x| n += usize::from(!x.is_container())); + n + }; + let mut printed = d.clone(); + if let Some(l) = printed.layers.get_mut(1).map(std::sync::Arc::make_mut) + && let NodeKind::Layer { printable, .. } = &mut l.kind + { + *printable = true; + } + assert!(count(d) < count(&printed), "{} vs {}", count(d), count(&printed)); +} + +#[test] +fn stroke_only_text_in_a_missing_font_gets_an_editable_copy_too() { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(uninstalled_font().into(), 0).unwrap(); + s.set_fill(None); + s.set_stroke(Some(krilla::paint::Stroke::default())); + s.draw_text(KPoint::from_xy(20.0, 50.0), font, 24.0, "Gagaku", false, TextDirection::Auto); + s.finish(); + page.finish(); + let d = import_with_report(&pdf.finish().unwrap(), &ImportOptions::default()).unwrap().document; + let copies = d.layers.get(1).expect("a layer of editable copies"); + let mut t = vec![]; + copies.walk(&mut |n| { + if let NodeKind::Text(x) = &n.kind { + t.push(x.plain_text()); + } + }); + assert_eq!(t, ["Gagaku"]); +} + +#[test] +fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyphs() { + use crate::import_text::{Look, Placement, TextLine, Upright}; + use vectorcraft_geom::{Point, Vec2}; + let look = Look { + font: 1, + family: "Source Sans 3".into(), + style: "Regular".into(), + size: 20.0, + h_scale: 100.0, + fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), + stroke: None, + }; + let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 }; + let mut line = TextLine::new(at(100.0, 100.0), 1.0); + assert!(line.push_upright(&look, at(100.0, 100.0), 1.0, Upright { top: Point::new(110.0, 82.0) }, "§")); + assert!(!line.push(&look, at(130.0, 100.0), 1.0, 10.0, "a"), "a horizontal glyph starts another line"); + let (t, _) = line.finish().unwrap(); + assert!(t.vertical, "one upright glyph is vertical type"); + assert_eq!(t.plain_text(), "§"); +} + +#[test] +fn letter_spaced_vertical_type_keeps_its_characters_together() { + use crate::import_text::{Look, Placement, TextLine, Upright}; + use vectorcraft_geom::{Point, Vec2}; + let look = Look { + font: 1, + family: "Source Sans 3".into(), + style: "Regular".into(), + size: 20.0, + h_scale: 100.0, + fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))), + stroke: None, + }; + // Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them. + let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0 }; + let mut line = TextLine::new(at(100.0), 1.0); + for (i, c) in ["§", "§", "§"].iter().enumerate() { + let y = 100.0 + 30.0 * i as f64; + assert!(line.push_upright(&look, at(y), 1.0, Upright { top: Point::new(110.0, y - 18.0) }, c)); + } + let (t, _) = line.finish().unwrap(); + assert!(t.vertical); + assert_eq!(t.plain_text(), "§§§", "letter spacing isn't a space"); +} + +#[test] +fn a_tagged_span_inside_a_line_leaves_the_line_whole() { + use krilla::tagging::{ContentTag, SpanTag}; + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap()); + let mut s = page.surface(); + let font = Font::new(SOURCE_SANS.into(), 0).unwrap(); + // "Ga" "ga" "ku" with the middle part in its own marked content (a span, as apps write for + // actual text), all on one baseline. + s.draw_text(KPoint::from_xy(20.0, 50.0), font.clone(), 24.0, "Ga", false, TextDirection::Auto); + let x = 20.0 + ink_width_of(&font, 24.0, "Ga"); + s.start_tagged(ContentTag::Span(SpanTag::empty())); + s.draw_text(KPoint::from_xy(x, 50.0), font.clone(), 24.0, "ga", false, TextDirection::Auto); + s.end_tagged(); + let x = x + ink_width_of(&font, 24.0, "ga"); + s.draw_text(KPoint::from_xy(x, 50.0), font, 24.0, "ku", false, TextDirection::Auto); + s.finish(); + page.finish(); + let d = import(&pdf.finish().unwrap()).unwrap(); + let t: Vec = texts(&d).iter().map(TextObject::plain_text).collect(); + assert_eq!(t, ["Gagaku"]); +} + +/// Advance of `text` in `font` at `size` (from the bundled Source Sans 3, as laid out). +fn ink_width_of(_font: &Font, size: f32, text: &str) -> f32 { + let style = vectorcraft_doc::CharStyle { font_family: "Source Sans 3".into(), size: f64::from(size), ..Default::default() }; + ink_width(&TextObject::point(vectorcraft_geom::Point::ZERO, text, style)) as f32 +} diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index 72761baa..abfbdcb4 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -211,3 +211,20 @@ fn pdf_a_takes_real_text() { let bytes = export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap(); assert_eq!(texts(&import_as(&bytes, TextAs::Text)), ["Archive"]); } + +/// A named instance comes back as live type in that style (not the default instance's). +#[test] +fn a_variable_font_instance_comes_back_in_its_own_style() { + use vectorcraft_text::test_fonts::{VARIABLE_CHARS, VARIABLE_FAMILY, variable_font}; + FontDb::global().add_font(variable_font().unwrap()); + let st = CharStyle { font_family: VARIABLE_FAMILY.into(), font_style: "Bold".into(), ..style(48.0) }; + let d = doc(vec![TextObject::point(Point::new(20.0, 80.0), VARIABLE_CHARS, st)]); + let back = crate::import_with_report(&pdf(&d, false).bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap().document; + let mut styles = vec![]; + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().map(|r| r.style.font_style.clone())); + } + }); + assert_eq!(styles, ["Bold"]); +}