diff --git a/Cargo.lock b/Cargo.lock index b42037f540..2c87e20851 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6664,6 +6664,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "siphasher", "skrifa 0.47.0", "smallvec", "thiserror 2.0.21", diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index a5a3b83863..d4394dfefa 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -30,6 +30,7 @@ craft-pdf = { workspace = true } log = { workspace = true } chrono = { workspace = true } smallvec = "1" +siphasher = "1" weezl = "0.2" serde = { workspace = true } # The XMP packet that File Info is read from (file_info.rs); the version usvg already uses. diff --git a/crates/pdf/src/import/actual_text.rs b/crates/pdf/src/import/actual_text.rs index d6d18a7566..7f754d3e37 100644 --- a/crates/pdf/src/import/actual_text.rs +++ b/crates/pdf/src/import/actual_text.rs @@ -92,4 +92,44 @@ impl Builder<'_> { } true } + + /// A Type 3 glyph under an active ActualText span. + pub(super) fn actual_type3_glyph( + &mut self, + key: u128, + at: Placement, + look: Look, + opacity: f32, + advance: f64, + ) -> bool { + self.flush_glyphs(); + self.flush_type3(); + if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke) + { + return true; + } + let pending = self.actual.pending.take(); + let content = match pending.as_deref() { + Some(text) if !text.is_empty() => TextContent::Actual(text), + _ => TextContent::Pen, + }; + let place = |line: &mut TextLine| { + line.push_content(&look, at, opacity, advance, content) + }; + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + if !place(&mut line) { + self.actual.pending = pending; + return false; + } + self.text = Some(line); + } + if let Some(line) = &mut self.text { + line.has_type3 = true; + } + true + } } diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 6d47168716..c7ceff4666 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -12,6 +12,18 @@ use super::{ Upright, Walked, clipped, contains, fold_stop_opacity, function_paint, gradient_shape, is_rectangle, luminance, mask_spec, shading_gradient, }; +fn is_colour_face(face: &vectorcraft_text::FontFace) -> bool { + use skrifa::Tag; + use skrifa::raw::TableProvider; + let Ok(font) = skrifa::FontRef::new(face.file_data()) else { + return false; + }; + font.colr().is_ok() + || font.table_data(Tag::new(b"SVG ")).is_some() + || font.table_data(Tag::new(b"CBDT")).is_some() + || font.table_data(Tag::new(b"sbix")).is_some() +} + impl<'p> Builder<'p> { pub(super) fn new(next: u64, colors: Colors<'p>, text_as: TextAs, route: bool) -> Self { Self { @@ -22,6 +34,7 @@ impl<'p> Builder<'p> { blend: BlendMode::Normal, page: Rect::ZERO, glyphs: None, + type3_run: None, text: None, text_glyphs: vec![], text_as, @@ -102,6 +115,7 @@ impl<'p> Builder<'p> { self.root_groups.clear(); self.blend = BlendMode::Normal; self.glyphs = None; + self.type3_run = None; self.text = None; self.text_glyphs.clear(); self.mask = None; @@ -221,13 +235,48 @@ impl<'p> Builder<'p> { } } - /// Finish the open glyph runs and the art drawn through the current soft mask. + /// Finish the open glyph runs, type 3 runs and the art drawn through the current soft mask. pub(super) fn flush(&mut self) { self.flush_glyphs(); + self.flush_type3(); self.flush_text(); self.flush_masked(); } + /// Finish the open Type 3 font text run as a named group ``. + pub(super) fn flush_type3(&mut self) { + if let Some(run) = self.type3_run.take() + && !run.nodes.is_empty() + { + let Some(left) = self.objects_left.checked_sub(1) else { + self.warn(super::entry::TOO_MANY_OBJECTS); + return; + }; + self.objects_left = left; + let mut g = Node::group(self.id(), run.nodes); + g.name = Some("".to_string()); + self.emit(Arc::new(g), run.group); + } + } + + /// Add nodes from an interpreted Type 3 glyph to the current Type 3 run. + pub(super) fn push_type3_nodes(&mut self, nodes: Vec>) { + if nodes.is_empty() { + return; + } + let group = self.current_group(); + if let Some(run) = &mut self.type3_run { + if run.group != group { + self.flush_type3(); + self.type3_run = Some(super::state::Type3Run { nodes, group }); + } else { + run.nodes.extend(nodes); + } + } else { + self.type3_run = Some(super::state::Type3Run { nodes, group }); + } + } + /// The art drawn through the graphics state's soft mask so far, as one masked object. fn flush_masked(&mut self) { if self.masked.is_empty() { @@ -391,7 +440,7 @@ impl<'p> Builder<'p> { let Some(mut line) = self.text.take() else { return false }; let held = std::mem::take(&mut line.held); // Type the file names itself (ActualText) is in reading order as it stands. - let checked = !glyphs.is_empty() && line.mapped(); + let checked = !glyphs.is_empty() && line.mapped() && !line.has_type3; let mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { @@ -509,10 +558,11 @@ impl<'p> Builder<'p> { /// Read a soft mask, pattern cell or type 3 glyph (`read`) on its own: its art, in the /// coordinates `page` has. `None` when nested too deeply. - fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { + pub(super) fn nested(&mut self, page: Rect, read: impl FnOnce(&mut Self)) -> Option>> { if self.nested >= MAX_NESTED { return None; } + let outer_type3 = self.type3_run.take(); self.flush(); let saved = Saved { stack: std::mem::replace(&mut self.stack, vec![Frame::new(FrameKind::Root)]), @@ -520,12 +570,14 @@ impl<'p> Builder<'p> { blend: std::mem::replace(&mut self.blend, BlendMode::Normal), mask: self.mask.take(), page: std::mem::replace(&mut self.page, page), + type3_run: outer_type3, }; self.nested += 1; read(self); let art = self.close_frames(); self.nested -= 1; - (self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page); + (self.stack, self.root_groups, self.blend, self.mask, self.page, self.type3_run) = + (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.type3_run); Some(art) } @@ -799,6 +851,125 @@ impl<'p> Builder<'p> { self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); } + fn type3_font_name(&mut self, key: u128) -> FontInfo { + if let Some(n) = self.font_names.get(&key) { + return n.clone(); + } + let name = self.fonts.get(&key).map(|f| f.name.clone()); + 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(), face: Some(face) }, + (None, Some(name)) => { + let families = self.families.get_or_insert_with(Families::available); + let (family, style, found) = families.resolve(&name, None, false); + if !found && !family.is_empty() && !self.missing_fonts.contains(&family) { + self.missing_fonts.push(family.clone()); + } + let face = vectorcraft_text::FontDb::global().face(&family, &style); + FontInfo { family, style, face } + } + (None, None) => { + let d = vectorcraft_doc::CharStyle::default(); + FontInfo { family: d.font_family, style: d.font_style, face: None } + } + }; + self.font_names.insert(key, n.clone()); + n + } + + /// Type 3 glyph as live type: added to the line being gathered if it has a ToUnicode mapping. + pub(super) fn type_type3_glyph( + &mut self, + t3: &craft_pdf::interpret::font::Type3Glyph<'_>, + m: Affine, + scale: f64, + paint: Option<&craft_pdf::interpret::Paint<'_>>, + stroke: Option<&StrokeProps>, + overprint: bool, + ) -> bool { + if self.text_as != TextAs::Text || self.nested > 0 { + return false; + } + let t3_key = craft_pdf::interpret::CacheKey::cache_key(t3); + let Some(&(font_key, has_to_unicode)) = self.scan.type3_cache_map.get(&t3_key) else { + return false; + }; + let (is_type3, to_unicode, is_colour) = match self.fonts.get(&font_key) { + Some(f) => (f.is_type3, has_to_unicode || f.to_unicode, f.is_colour), + None => (true, has_to_unicode, false), + }; + let authored = self.actual.active(); + if !is_type3 || (!to_unicode && !authored) || is_colour { + return false; + } + let unicode = t3.as_unicode().filter(|u| !super::cid::replacement(u)); + if !authored && unicode.is_none() { + return false; + } + let text: String = match unicode { + Some(BfString::Char(c)) => c.to_string(), + Some(BfString::String(s)) => s, + None => String::new(), + }; + let Some(at) = Placement::of(m) else { return false }; + if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) { + return false; + } + let width = t3.advance_width().filter(|w| w.is_finite()); + let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); + let info = self.type3_font_name(font_key); + if info.face.as_ref().is_some_and(|f| is_colour_face(f)) { + return false; + } + let (paint, opacity) = match paint { + Some(p) => { + let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true)); + (Some(paint), opacity) + } + None => (None, 1.0), + }; + let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); + let look = Look { + font: font_key, + family: info.family, + style: info.style, + version: None, + size: at.size, + h_scale: at.h_scale, + overprint_fill: stroke.is_none() && overprint, + overprint_stroke: stroke.is_some() && overprint, + fill: if stroke.is_none() { paint } else { None }, + stroke, + }; + if authored { + return self.actual_type3_glyph(font_key, at, look, opacity, advance); + } + let text = visual_glyph_text(text); + let place = |line: &mut TextLine| line.push(&look, at, opacity, advance, &text); + self.flush_glyphs(); + self.flush_type3(); + if let (Some(s), Some(line)) = (&look.stroke, &mut self.text) + && look.fill.is_none() + && line.stroke_last(font_key, at, s.clone(), overprint) + { + return true; + } + if !self.text.as_mut().is_some_and(place) { + self.flush_text(); + let mut line = TextLine::new(at, opacity); + place(&mut line); + self.text = Some(line); + } + if let Some(line) = &mut self.text { + line.has_type3 = true; + } + true + } + /// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps /// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text, /// type with neither fill nor stroke. diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index b39905d6e0..b145f2d7f2 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -33,6 +33,7 @@ impl<'a> Device<'a> for Builder<'_> { fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) { self.flush_glyphs(); + self.flush_type3(); let walked = self.walked(false); if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) { return; @@ -174,7 +175,7 @@ impl<'a> Device<'a> for Builder<'_> { GlyphDrawMode::Invisible => { let typed = match glyph { Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false), - Glyph::Type3(_) => false, + Glyph::Type3(t3) => self.type_type3_glyph(t3, transform * glyph_transform, mean_scale(transform), None, None, false), }; if !typed { self.warn(INVISIBLE_TEXT_NOTE); @@ -186,6 +187,7 @@ impl<'a> Device<'a> for Builder<'_> { }; match glyph { Glyph::Outline(o) => { + self.flush_type3(); let scale = mean_scale(transform); let overprint = self.glyph_overprint(stroke.is_some()); if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { @@ -216,16 +218,26 @@ impl<'a> Device<'a> for Builder<'_> { if self.nested >= MAX_NESTED { return; } - self.flush(); - self.nested += 1; - t3.interpret(self, transform, glyph_transform, paint); - self.nested -= 1; + let scale = mean_scale(transform); + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_type3_glyph(t3, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { + return; + } + self.flush_glyphs(); + self.flush_text(); + let art = self.nested(self.page, |b| { + t3.interpret(b, transform, glyph_transform, paint); + }); + if let Some(nodes) = art { + self.push_type3_nodes(nodes); + } } } } fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { self.flush_glyphs(); + self.flush_type3(); self.flush_text(); match image { Image::Raster(r) => { diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index b74cbf5dab..0470d641e2 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -62,6 +62,13 @@ pub(super) struct GlyphRun { /// swatch made for it. pub(super) type PatternReadings = (u32, Vec<(Vec, String)>); +/// A run of Type 3 glyph graphics preserved inside a named group. +#[derive(Clone, Debug)] +pub(super) struct Type3Run { + pub(super) nodes: Vec>, + pub(super) group: Option, +} + /// Builder state a nested interpretation (soft mask, pattern cell) starts afresh and gives back. pub(super) struct Saved { pub(super) stack: Vec, @@ -69,6 +76,7 @@ pub(super) struct Saved { pub(super) blend: BlendMode, pub(super) mask: Option>, pub(super) page: Rect, + pub(super) type3_run: Option, } pub(super) struct Builder<'p> { @@ -80,6 +88,7 @@ pub(super) struct Builder<'p> { pub(super) blend: BlendMode, pub(super) page: Rect, pub(super) glyphs: Option, + pub(super) type3_run: Option, pub(super) text: Option, /// The glyphs of the open line of type (`text`) that show, each as the outline it would be /// kept as: what the line becomes when its type wouldn't draw as the file does diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 4f4e35dd3e..5e87fcf964 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -13,6 +13,8 @@ use std::collections::{HashMap, HashSet}; +use siphasher::sip128::{Hasher128, SipHasher13}; + use crate::import_color::items; use craft_pdf::interpret::CacheKey; use craft_pdf::syntax::Pdf; @@ -250,6 +252,13 @@ fn pdf_doc_char(c: u8) -> char { mapped.copied().unwrap_or(char::from(c)) } +/// Calculate a 128-bit siphash of a value, matching hayro-interpret's `hash128`. +pub(crate) fn hash128(value: &T) -> u128 { + let mut state = SipHasher13::new(); + value.hash(&mut state); + state.finish128().as_u128() +} + /// One page as the interpreter walks it. #[derive(Default)] pub(crate) struct Scan { @@ -282,6 +291,8 @@ pub(crate) struct Scan { /// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an /// ink is shown in the same one. text_inks: Changes<[Option; 2]>, + /// Type 3 glyph cache key -> (font cache key, whether font has /ToUnicode). + pub type3_cache_map: HashMap, } impl Scan { @@ -642,9 +653,65 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::>(key.as_ref()) && let Some(name) = font_name(&f) { - self.fonts.entry(f.cache_key()).or_insert_with(|| { + let is_type3 = f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3"); + let to_unicode = f.get::>(b"ToUnicode").is_some(); + let f_key = f.cache_key(); + const MAX_TYPE3_CACHE_MAP: usize = 1 << 20; + if is_type3 && self.out.type3_cache_map.len().saturating_add(257) <= MAX_TYPE3_CACHE_MAP { + for gid in 0..=256u32 { + let hash = hash128(&(f_key, skrifa::GlyphId::new(gid))); + self.out.type3_cache_map.insert(hash, (f_key, to_unicode)); + } + } + let is_colour = if is_type3 { + let mut colour = false; + let mut seen_streams = HashSet::new(); + let mut decoded_bytes = 0usize; + const MAX_CHARPROC_BYTES: usize = 16 << 20; + if let Some(char_procs) = f.get::>(b"CharProcs") { + for key in char_procs.keys() { + if let Some(stream) = char_procs.get::>(key.as_ref()) { + if !seen_streams.insert(stream.cache_key()) { + continue; + } + if decoded_bytes >= MAX_CHARPROC_BYTES { + colour = true; + break; + } + if let Ok(data) = stream.decoded() { + decoded_bytes = decoded_bytes.saturating_add(data.len()); + let mut iter = TypedIter::new(data.as_ref()); + while let Some(op) = iter.next() { + match op { + TypedInstruction::ColorGlyph(_) => { + colour = true; + break; + } + TypedInstruction::ShapeGlyph(_) => break, + _ => {} + } + } + if colour { + break; + } + } + } + } + } + colour + } else { + false + }; + self.fonts.entry(f_key).or_insert_with(|| { let embedded = embeds_glyphs(&f); - FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) } + FontNote { + name, + embedded, + vertical: vertical_metrics(&f, embedded), + is_type3, + to_unicode, + is_colour, + } }); } } @@ -879,6 +946,12 @@ pub(crate) struct FontNote { pub embedded: bool, /// A composite font written vertically: its descendant's metrics. pub vertical: Option, + /// Whether this is a Type 3 font. + pub is_type3: bool, + /// Whether this font specifies a /ToUnicode map. + pub to_unicode: bool, + /// Whether this Type 3 font contains coloured glyph descriptions (d0/ColorGlyph). + pub is_colour: bool, } /// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has). @@ -1020,6 +1093,15 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) { /// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one, /// the `BaseFont` without the CMap's name on its end. fn font_name(f: &Dict<'_>) -> Option { + if f.get::>(b"Subtype").is_some_and(|s| s.as_ref() == b"Type3") { + if let Some(base) = f.get::>(b"BaseFont") { + return Some(String::from_utf8_lossy(base.as_ref()).into_owned()); + } + if let Some(name) = f.get::>(b"Name") { + return Some(String::from_utf8_lossy(name.as_ref()).into_owned()); + } + return Some("Type3".to_string()); + } let base = String::from_utf8_lossy(f.get::>(b"BaseFont")?.as_ref()).into_owned(); if f.get::>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") { return Some(base); diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index 200ec93673..9b0122326f 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -218,6 +218,8 @@ pub(crate) struct TextLine { /// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far /// (written right after the line). pub held: Vec, + /// Whether this line holds Type 3 font glyphs. + pub has_type3: bool, } /// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font @@ -266,6 +268,7 @@ impl TextLine { drawn_spaces: vec![], drawn_space: None, held: vec![], + has_type3: false, } } diff --git a/crates/pdf/src/tests/text_placement.rs b/crates/pdf/src/tests/text_placement.rs index f275def4dd..417a88e5b9 100644 --- a/crates/pdf/src/tests/text_placement.rs +++ b/crates/pdf/src/tests/text_placement.rs @@ -112,6 +112,25 @@ fn type3(first: usize, upm: f64) -> Vec { vec![font, a, b] } +/// A Type 3 font with a ToUnicode CMap. +fn type3_with_to_unicode(first: usize, upm: f64) -> Vec { + let s = 1.0 / upm; + let (h, w) = (upm * 0.5, upm); + let a = stream("", &format!("{h} 0 0 0 {h} {upm} d1 0 0 {} {} re f", h * 0.8, upm * 0.7)); + let b = stream("", &format!("{w} 0 0 0 {w} {upm} d1 0 0 {} {} re f", w * 0.9, upm * 0.7)); + let to_u = stream( + "", + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /T3-UCS def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 2 beginbfchar\n<61> <0061>\n<62> <0062>\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + ); + let font = format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /ToUnicode {} 0 R /Resources << >> >>", + first + 1, + first + 2, + first + 3 + ); + vec![font, a, b, to_u] +} + /// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts: /// - `/F1`: Source Sans 3, embedded, a simple TrueType font; /// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]); @@ -146,9 +165,13 @@ fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec { let t3 = n + objs.len(); objs.extend(type3(t3, 1000.0)); objs.extend(type3(t3 + 3, 100.0)); + let t3u = t3 + 6; + objs.extend(type3_with_to_unicode(t3u, 1000.0)); + let t30u = t3u + 4; + objs.extend(type3_with_to_unicode(t30u, 100.0)); let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); let resources = format!( - "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", + "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /T3U {t3u} 0 R /T30U {t30u} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", n + 2, n + 5, n + 6, @@ -416,7 +439,16 @@ fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() { fn type3_glyphs_follow_the_font_matrix() { for font in ["T3", "T30"] { let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text); - assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines"); + assert!(texts(&d).is_empty(), "Type 3 text without ToUnicode keeps its outlines"); + let mut groups = 0; + d.walk(|n| { + if let NodeKind::Group { .. } = &n.kind + && n.name.as_deref() == Some("") + { + groups += 1; + } + }); + assert_eq!(groups, 1, "{font}: Type 3 text run must be preserved inside named group "); let mut boxes = contours(&d); boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); // `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall, @@ -429,6 +461,60 @@ fn type3_glyphs_follow_the_font_matrix() { } } +/// Type 3 fonts with ToUnicode import as live text in Text mode, and as named groups in Paths mode. +/// Tests both 1000 UPM (/T3U) and 100 UPM (/T30U) to verify FontMatrix scaling. +#[test] +fn type3_font_with_to_unicode_imports_as_live_text() { + for font in ["T3U", "T30U"] { + let d_text = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text); + let t = texts(&d_text); + assert_eq!(t.len(), 1, "{font}: Type 3 with ToUnicode should import as live text"); + assert_eq!(t[0].plain_text(), "ab"); + let run = &t[0].runs[0]; + assert!((run.style.size - 20.0).abs() < 1e-3, "{font}: font size should be 20 pt, got {}", run.style.size); + + let d_paths = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Outlines); + assert!(texts(&d_paths).is_empty(), "In Outlines mode, Type 3 text should be outlines"); + let mut groups = 0; + d_paths.walk(|n| { + if let NodeKind::Group { .. } = &n.kind + && n.name.as_deref() == Some("") + { + groups += 1; + } + }); + assert_eq!(groups, 1, "In Paths mode, Type 3 text should be grouped in "); + } +} + +/// Invisible Type 3 text (mode 3) with ToUnicode imports as searchable text. +#[test] +fn type3_invisible_with_to_unicode_imports_as_searchable_text() { + let d = open_as(&page("BT /T3U 20 Tf 3 Tr 20 50 Td (ab) Tj ET", ""), TextAs::Text); + let t = texts(&d); + assert_eq!(t.len(), 1, "Invisible Type 3 text with ToUnicode imports as searchable text"); + assert_eq!(t[0].plain_text(), "ab"); + for r in &t[0].runs { + assert!(r.style.fill.is_none(), "Invisible text has no fill: {:?}", r.style.fill); + assert!(!r.style.has_stroke(), "Invisible text has no stroke"); + } +} + +/// Type 3 text with ActualText replacement honors authored text or suppression. +#[test] +fn type3_with_actual_text() { + // Authored replacement "XYZ" over Type 3 glyphs (ab) + let d = open_as(&page("/Span << /ActualText (XYZ) >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text); + let t = texts(&d); + assert_eq!(t.len(), 1, "Type 3 with ActualText should import as live text"); + assert_eq!(t[0].plain_text(), "XYZ"); + + // Empty ActualText suppresses mapped text + let d_empty = open_as(&page("/Span << /ActualText () >> BDC BT /T3U 20 Tf 20 50 Td (ab) Tj ET EMC", ""), TextAs::Text); + let t_empty = texts(&d_empty); + assert!(t_empty.is_empty(), "Empty ActualText should suppress text"); +} + /// A font that isn't embedded or installed is set in a stand-in: the line still starts where /// the file starts it, on its baseline, and is as long as the file's. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index 782d6a27b8..d386be463f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-13 · **Change:** minor (PDF import: preserve Type 3 font text runs as named groups , or live text if ToUnicode exists, #6740; scores and estimates unchanged; previously: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -34,6 +34,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | PDF import: preserve Type 3 font text runs as named groups ``, or as live text if `/ToUnicode` exists on non-colour fonts (#6740); scores and estimates unchanged. | | 2026-11-08 | minor | #3380, deletion only: preserve gradient placement and exact surviving geometry; consume owned subpaths with a shared original-node snapshot; consolidate status/evidence and document command/helper contracts. Compound behavior and the existing M2 status entry are retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | #3380 (deletion only): independent surviving path objects; transformed appearance, stacking, selection, cubic handles and Undo/Redo covered by `delete_path_pieces`; compound semantics retained. Endpoint highlighting remains open; scores and estimates unchanged. | | 2026-11-07 | minor | Indian-language hyphenation (#4947): eleven MIT-licensed pattern sets, with grapheme and shaping-cluster protection in both composers and existing paragraph limits. Assamese and Sanskrit are selectable; Sanskrit automatic patterns remain pending an allowlisted source. Scores and estimates unchanged. | @@ -192,6 +193,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G9 Illustrator `.ai` and EPS files from real users +- **Done (#6740, 2026-11-13):** PDF import preserves Type 3 font text runs as named groups `` when drawn as outlines or color graphics, and reconstructs live text objects when `/ToUnicode` exists and the font is not a colour font (`crates/pdf/src/tests/text_placement.rs`). Scores and estimates unchanged. + - **Done (#6388, 2026-11-10):** a saved `.ai`'s text document names, for type in a variable font, where in the font's design space it is set (each axis, the named instance's or the run's own setting) and the font's version, so Montserrat Bold no longer opens as the font's default instance (Thin); two settings of one font are two fonts of the set. Test `ai_editing::tests::variable_font_instances_name_their_design_space`. The PDF part's font naming (#4548) remains with craft-libs. Estimate unchanged. - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate.