diff --git a/NOTICE b/NOTICE index ba97f852..80cb5432 100644 --- a/NOTICE +++ b/NOTICE @@ -34,3 +34,35 @@ Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official rel fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (today BIZ UDPGothic, Shippori Mincho and BIZ UDMincho; web builds BIZ UDPGothic Regular only) under the SIL Open Font License 1.1. Their license texts ship with the release packages as OFL-.txt. + +PDF import decodes CJK text with Adobe's CMap resources (https://github.com/adobe-type-tools/cmap-resources), +compiled into the app through the hayro-cmap crate under the BSD 3-Clause License: + +Copyright 1990-2023 Adobe. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +Redistributions of source code must retain the above copyright notice, +this list of conditions and the following disclaimer. + +Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +Neither the name of Adobe nor the names of its contributors may be +used to endorse or promote products derived from this software without +specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/ROADMAP.md b/ROADMAP.md index c340cddf..cded9803 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -139,7 +139,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. 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. +- **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 comes in as live type shown in the fallback font until the font is installed. - **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/src/import.rs b/crates/pdf/src/import.rs index 171d639f..fd22a6e7 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -112,7 +112,6 @@ 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 { + pub(crate) 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; @@ -283,20 +267,12 @@ impl CidText { /// 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 Some(c) = comparable(text, vertical) else { return Some(0.0) }; let gid = face.glyph_for(c); if gid == 0 { return None; @@ -346,6 +322,17 @@ fn matching_face( best.map(|(_, f)| f) } +/// The character of glyph `text` when its outline can be compared with an installed font's: a +/// single character, and not CJK punctuation set vertically (its vertical form differs). +fn comparable(text: &str, vertical: bool) -> Option { + let mut chars = text.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return None }; + if vertical && matches!(c as u32, 0x2014 | 0x2015 | 0x2025 | 0x2026 | 0x3000..=0x303F | 0x30FC | 0xFE30..=0xFE4F | 0xFF00..=0xFF65) { + return None; + } + Some(c) +} + 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 } @@ -356,8 +343,6 @@ pub(crate) fn unify_radical(c: char) -> char { struct FontInfo { family: String, style: String, - /// Available (installed or loaded). - found: bool, /// The installed face of its PostScript name. face: Option>, } @@ -459,11 +444,9 @@ struct Builder<'p> { /// 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>, + /// Font (cache key) → the installed face that draws its glyphs, decided from its first glyph + /// that can be compared ([`matching_face`]); `None`: its glyphs differ, so it stays outlines. + matched: HashMap>>, /// The soft mask of the graphics state, the art drawn through it so far, and the masks read. mask: Option>, masked: Vec>, @@ -578,8 +561,7 @@ impl<'p> Builder<'p> { families: None, missing_fonts: vec![], cid_text: HashMap::new(), - copy_line: None, - copies: vec![], + matched: HashMap::new(), mask: None, masked: vec![], masks: HashMap::new(), @@ -632,8 +614,6 @@ 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(); @@ -658,7 +638,6 @@ 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(); } @@ -1016,23 +995,18 @@ impl<'p> Builder<'p> { }); 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) }, + (Some(face), _) => FontInfo { family: face.family.clone(), style: face.style.clone(), 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()); } - FontInfo { family, style, found, face: None } + FontInfo { family, style, face: None } } (None, None) => { let d = vectorcraft_doc::CharStyle::default(); - FontInfo { family: d.font_family, style: d.font_style, found: true, face: None } + FontInfo { family: d.font_family, style: d.font_style, face: None } } }; self.font_names.insert(key, n.clone()); @@ -1053,16 +1027,20 @@ impl<'p> Builder<'p> { return false; } 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 (unicode, from_cid) = match o.as_unicode() { + Some(u) => (Some(u), false), + None => { + 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())), true) + } + }; 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(); + // Adobe's CID tables map some ideographs to their look-alike Kangxi radicals. + let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text }; let Some(at) = Placement::of(m) else { return false }; if text.is_empty() || text.chars().any(|c| c.is_control()) { return false; @@ -1073,11 +1051,22 @@ impl<'p> Builder<'p> { 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 { + let decided = match self.matched.get(&key) { + Some(m) => m.clone(), + // A glyph that can't be compared (several characters, a vertical form) decides + // nothing: it takes the face of its name until one that can be compared does. + None if comparable(&text, upright).is_none() => Some(face.clone()), + None => { + let m = matching_face(face, &text, o, upright); + self.matched.insert(key, m.clone()); + m + } + }; + let Some(f) = decided 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) }; + info = FontInfo { family: f.family.clone(), style: f.style.clone(), 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)); @@ -1094,24 +1083,6 @@ impl<'p> Builder<'p> { 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)) = (&look.stroke, &mut self.text) @@ -1128,15 +1099,6 @@ impl<'p> Builder<'p> { } 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> { diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 9d2f816c..0d011715 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -247,14 +247,6 @@ 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. @@ -293,16 +285,20 @@ impl TextLine { // 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 laid = vectorcraft_text::layout(db, &t); + let natural: f64 = laid.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; + let mut bounds = laid.bounds; 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; } + // Tracked: laid out again for its bounds. + bounds = vectorcraft_text::layout(db, &t).bounds; } - t.cached_bounds = Some(vectorcraft_text::layout(db, &t).bounds); + t.cached_bounds = Some(bounds); Some((t, self.opacity)) } } diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index 86652c0c..8270d83a 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -98,6 +98,31 @@ fn letter_spacing_in_the_pdf_becomes_tracking() { assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0])); } +/// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic. +#[test] +fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() { + assert!(crate::import::CidText::of(&[]).is_none()); + assert!(crate::import::CidText::of(b"not a font at all").is_none()); + let mut x: u32 = 0x9E37_79B9; + for len in [4usize, 64, 1024, 65_536] { + let junk: Vec = (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect(); + assert!(crate::import::CidText::of(&junk).is_none(), "{len} bytes"); + // A CFF header in front of the junk. + let mut cff = vec![1, 0, 4, 4]; + cff.extend_from_slice(&junk); + let _ = crate::import::CidText::of(&cff); + } + let sans = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + assert!(crate::import::CidText::of(&sans).is_none(), "a TrueType font has no CID charset"); +} + #[test] fn kangxi_radicals_from_cid_tables_become_ideographs() { assert_eq!(crate::import::unify_radical('\u{2FD3}'), '龍'); @@ -148,75 +173,29 @@ fn uninstalled_font() -> Vec { } #[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()); +fn text_in_a_missing_font_stays_live_type() { + for stroke_only in [false, true] { + 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(); + if stroke_only { + s.set_fill(None); + s.set_stroke(Some(krilla::paint::Stroke::default())); } - }); - 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; + 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(); + assert_eq!(r.document.layers.len(), 1, "no extra layers"); + let t = texts(&r.document); + assert_eq!(t.len(), 1, "stroke only {stroke_only}"); + assert_eq!(t[0].plain_text(), "Gagaku"); + // Named by the family its PostScript name reads as, so it picks the font up once installed. + let family = t[0].first_style().font_family.clone(); + assert_eq!(family, "Source Sans 9"); + assert!(r.warnings.iter().any(|w| w.contains(family.as_str()) && w.contains("fallback font")), "{:?}", r.warnings); } - 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]