From f18fb4e92ded4e5c4e8db80d183198365ba79fe9 Mon Sep 17 00:00:00 2001 From: ruchirguitar Date: Thu, 12 Nov 2026 20:56:16 +0530 Subject: [PATCH] PDF import: Indic type that would not draw as the file does keeps the file's glyphs (#5235) Gujarati text from a PDF came in jumbled even with its fonts installed: - A glyph with no advance (a vowel sign or anusvara set over its letter) was taken for vertical type and opened as an object of its own, which type then drew on a dotted circle. In a font the file writes horizontally it now stays on its line. - A line read glyph by glyph is not always the text that was set: the file draws a vowel sign before the consonant it follows, and may not name a conjunct. A line with characters of U+0900 to U+0DFF is now set again and its glyphs compared with the ink the file drew; when they differ the line keeps the file's glyphs as outlines, with a note. Tests: crates/pdf/src/tests/reshaped.rs (two fail without the change). --- crates/pdf/src/import.rs | 5 +- crates/pdf/src/import/builder.rs | 89 +++++++++++++++++++--- crates/pdf/src/import/device.rs | 3 +- crates/pdf/src/import/entry.rs | 12 ++- crates/pdf/src/import/reshaped.rs | 53 +++++++++++++ crates/pdf/src/import/state.rs | 4 + crates/pdf/src/import_text.rs | 6 ++ crates/pdf/src/lib.rs | 4 +- crates/pdf/src/tests.rs | 1 + crates/pdf/src/tests/reshaped.rs | 119 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 3 +- 11 files changed, 282 insertions(+), 17 deletions(-) create mode 100644 crates/pdf/src/import/reshaped.rs create mode 100644 crates/pdf/src/tests/reshaped.rs diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index ffc185a575..d978680d67 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -35,6 +35,7 @@ mod builder; mod cid; mod device; mod entry; +mod reshaped; mod state; #[cfg(test)] @@ -42,8 +43,8 @@ pub(crate) use cid::{CidText, font_chars, identify_glyph, unify_radical}; #[cfg(any(test, feature = "read-count"))] pub use entry::imports_of; pub use entry::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(test)] pub(crate) use entry::{TEST_MAX_OBJECTS, import_with_reader_options, read}; diff --git a/crates/pdf/src/import/builder.rs b/crates/pdf/src/import/builder.rs index 945c082a26..6d47168716 100644 --- a/crates/pdf/src/import/builder.rs +++ b/crates/pdf/src/import/builder.rs @@ -1,9 +1,10 @@ use super::cid::{CidText, FontInfo, comparable, font_chars, identify_glyph, matching_face, replacement, unify_radical}; use super::entry::{ - MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, + MAX_NESTED, OVERPRINT_UNREAD, OVERPRINT_ZERO_MODE, PATTERN_REUSE, RESHAPED_TEXT_NOTE, TEXT_OUTLINES, TOO_MANY_OBJECTS, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, max_objects, }; -use super::state::{Builder, Frame, FrameKind, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; +use super::reshaped; +use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer}; use super::{ Affine, Appearance, AppearanceItem, Arc, BezPath, BfString, BlendMode, CacheKey, Color, Colors, Families, FillLayer, FillRule, FunctionBudget, FunctionPaint, HashMap, HashSet, ImageBlob, ImageObject, Knockout, Look, MaskSpec, MaskType, Native, Node, NodeId, NodeKind, OpacityMask, Paint, @@ -22,6 +23,7 @@ impl<'p> Builder<'p> { page: Rect::ZERO, glyphs: None, text: None, + text_glyphs: vec![], text_as, actual: Default::default(), images: HashMap::new(), @@ -101,6 +103,7 @@ impl<'p> Builder<'p> { self.blend = BlendMode::Normal; self.glyphs = None; self.text = None; + self.text_glyphs.clear(); self.mask = None; self.masked.clear(); self.marked.clear(); @@ -376,18 +379,56 @@ impl<'p> Builder<'p> { } pub(super) fn flush_text(&mut self) { - let Some(mut line) = self.text.take() else { return }; + if self.end_text() { + self.flush_glyphs(); + } + } + + /// Finish the open line of type. `true`: it was kept as outlines instead ([`reshaped`]), which + /// are left open as the glyph run for an outline drawn next to join. + pub(super) fn end_text(&mut self) -> bool { + let glyphs = std::mem::take(&mut self.text_glyphs); + 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 mut outlined = false; if let Some((t, opacity, facts)) = line.finish() { - let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); - n.opacity = opacity; - self.lines.insert(n.id, facts); - self.push_node(n); + if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) { + outlined = true; + for g in glyphs { + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == g.paint + && r.opacity == g.opacity + && r.overprint == g.overprint + && r.stroke.is_some() == g.stroke.is_some() + && r.stroke.as_ref().map(|s| s.line_width) == g.stroke.as_ref().map(|s| s.line_width) + && (r.scale - g.scale).abs() < 1e-9 + }); + match &mut self.glyphs { + Some(r) if same => r.path.extend(g.path.iter()), + _ => { + self.flush_glyphs(); + self.glyphs = Some(g); + } + } + } + self.warn(RESHAPED_TEXT_NOTE); + } else { + let mut n = Node::new(self.id(), NodeKind::Text(Box::new(t))); + n.opacity = opacity; + self.lines.insert(n.id, facts); + self.push_node(n); + } + } + if outlined && !held.is_empty() { + self.flush_glyphs(); } // The strokes of its glyphs' outlines, right after it ([`import_lines`] makes them its stroke). for n in held { self.push_node(n); } + outlined } /// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph @@ -737,6 +778,27 @@ impl<'p> Builder<'p> { n } + /// Glyph `o` of the open line of type, placed by `m` and drawn with `drawn` (its paint, and its + /// stroke when it is stroked), as the outline it is kept as if the line is + /// ([`Self::end_text`]). Nothing for a glyph that shows nothing. + fn keep_glyph( + &mut self, + o: &craft_pdf::interpret::font::OutlineGlyph, + m: Affine, + drawn: Option<(Paint, Option)>, + opacity: f32, + scale: f64, + overprint: bool, + ) { + let Some((paint, stroke)) = drawn else { return }; + let mut path = o.outline(); + if path.elements().is_empty() { + return; + } + path.apply_affine(m); + self.text_glyphs.push(GlyphRun { path, paint, opacity, stroke, scale, overprint }); + } + /// 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. @@ -794,8 +856,11 @@ impl<'p> Builder<'p> { return false; } let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance. - let upright = width.is_some_and(|w| w.abs() <= 1.0); + // Set vertically (WMode 1): no horizontal advance. Not in a font the file says is written + // horizontally: there a glyph without an advance is a mark over the letter before it (a + // vowel sign, an anusvara), which stays on its line (#5235). + let horizontal = self.fonts.get(&key).is_some_and(|f| f.vertical.is_none()); + let upright = !horizontal && width.is_some_and(|w| w.abs() <= 1.0); let mut info = self.font_name(key, o); // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which // style of the installed family it is, so the name decides. @@ -841,6 +906,8 @@ impl<'p> Builder<'p> { } None => (None, 1.0), }; + // What the glyph is drawn with as an outline, should its line be kept so. + let drawn = paint.clone().map(|p| (p, stroke.cloned())); let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale))); let look = Look { font: key, @@ -879,6 +946,7 @@ impl<'p> Builder<'p> { && look.fill.is_none() && line.stroke_last(key, at, s.clone(), overprint) { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); return true; } if !self.text.as_mut().is_some_and(place) { @@ -889,6 +957,9 @@ impl<'p> Builder<'p> { } // Its ink: a stroke the file draws as outlines of the glyphs is told by it. let mut ink = o.outline(); + if top.is_none() { + self.keep_glyph(o, m, drawn, opacity, scale, overprint); + } if let Some(line) = &mut self.text && !ink.elements().is_empty() { diff --git a/crates/pdf/src/import/device.rs b/crates/pdf/src/import/device.rs index c21e51dbb5..b39905d6e0 100644 --- a/crates/pdf/src/import/device.rs +++ b/crates/pdf/src/import/device.rs @@ -191,7 +191,8 @@ impl<'a> Device<'a> for Builder<'_> { if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) { return; } - self.flush_text(); + // A line kept as outlines leaves them open: this glyph joins them. + self.end_text(); let mut bp = o.outline(); bp.apply_affine(transform * glyph_transform); let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true)); diff --git a/crates/pdf/src/import/entry.rs b/crates/pdf/src/import/entry.rs index 38f22689a8..8503b4fb20 100644 --- a/crates/pdf/src/import/entry.rs +++ b/crates/pdf/src/import/entry.rs @@ -171,14 +171,22 @@ pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's /// Warning: glyphs that differ from the installed font of their name were kept as outlines. pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines"; +/// Warning: type in a script whose letters reorder and join (Indic scripts) that, set again from +/// its characters, wouldn't draw the glyphs where the file drew them was kept as outlines (#5235). +pub const RESHAPED_TEXT_NOTE: &str = + "text that would not look as it does in the file if set as type (a script whose letters join) was kept as outlines"; + /// The end of the warning that glyphs whose characters the file doesn't name were kept as /// outlines, after "text in" and the font's name. pub const UNNAMED_CHARACTERS_NOTE: &str = "whose characters the file doesn't name (U+FFFD) was kept as outlines"; /// Is `note` one of the warnings that glyphs were kept as outlines ([`OUTLINED_TEXT_NOTE`], -/// [`UNMATCHED_GLYPHS_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? +/// [`UNMATCHED_GLYPHS_NOTE`], [`RESHAPED_TEXT_NOTE`], [`UNNAMED_CHARACTERS_NOTE`])? pub fn is_outlines_note(note: &str) -> bool { - note == OUTLINED_TEXT_NOTE || note == UNMATCHED_GLYPHS_NOTE || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) + note == OUTLINED_TEXT_NOTE + || note == UNMATCHED_GLYPHS_NOTE + || note == RESHAPED_TEXT_NOTE + || note.starts_with("text in ") && note.ends_with(UNNAMED_CHARACTERS_NOTE) } /// Is `n` glyphs kept as outlines (a path the import names ``)? diff --git a/crates/pdf/src/import/reshaped.rs b/crates/pdf/src/import/reshaped.rs new file mode 100644 index 0000000000..77d70dfc1c --- /dev/null +++ b/crates/pdf/src/import/reshaped.rs @@ -0,0 +1,53 @@ +//! Type in a script whose letters reorder and join as they are set (#5235). A PDF draws such text +//! as glyphs already shaped: a vowel sign before the consonant it follows in reading order, a +//! conjunct as one glyph that the file may not name, a mark placed over its base. Read glyph by +//! glyph, the characters don't always make the text that was set, and type made from them draws +//! other glyphs in other places. A line of such type is therefore set again and compared with what +//! the file drew; one that would look different keeps the file's glyphs as outlines. + +use kurbo::{Rect, Shape}; +use vectorcraft_doc::TextObject; + +/// Does `text` have a character of a script whose letters reorder and join into conjuncts: the +/// Brahmic scripts of U+0900 to U+0DFF (Devanagari, Bengali, Gurmukhi, Gujarati, Oriya, Tamil, +/// Telugu, Kannada, Malayalam, Sinhala)? +pub(super) fn reshapes(text: &str) -> bool { + text.chars().any(|c| ('\u{0900}'..='\u{0DFF}').contains(&c)) +} + +/// Would type `t` draw its glyphs where the file drew them (`ink`: each glyph's ink box in +/// document space)? As many glyphs with ink, each within 3 % of the type's size of one of the +/// file's. +pub(super) fn draws_as(t: &TextObject, ink: &[Rect]) -> bool { + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut set: Vec = + laid.glyphs.iter().filter(|g| !g.outline.elements().is_empty()).map(|g| (t.xf * g.outline.clone()).bounding_box()).collect(); + if set.len() != ink.len() { + return false; + } + let size = t.runs.iter().map(|r| r.style.size).fold(0.0, f64::max); + let tol = size * 0.03; + if !tol.is_finite() || set.iter().chain(ink).any(|r| !(r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite())) { + return false; + } + set.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + let mut used = vec![false; set.len()]; + // Each of the file's glyphs takes one the type draws in its place: looked for among those + // whose left edge is as near. + ink.iter().all(|r| { + let from = set.partition_point(|s| s.x0 < r.x0 - tol); + let found = set + .iter() + .zip(used.iter_mut()) + .skip(from) + .take_while(|(s, _)| s.x0 <= r.x0 + tol) + .find(|(s, used)| !**used && (s.y0 - r.y0).abs() <= tol && (s.x1 - r.x1).abs() <= tol && (s.y1 - r.y1).abs() <= tol); + match found { + Some((_, used)) => { + *used = true; + true + } + None => false, + } + }) +} diff --git a/crates/pdf/src/import/state.rs b/crates/pdf/src/import/state.rs index 419952b017..b74cbf5dab 100644 --- a/crates/pdf/src/import/state.rs +++ b/crates/pdf/src/import/state.rs @@ -81,6 +81,10 @@ pub(super) struct Builder<'p> { pub(super) page: Rect, pub(super) glyphs: 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 + /// ([`super::reshaped`]). + pub(super) text_glyphs: Vec, pub(super) text_as: TextAs, pub(super) actual: super::actual_text::ActualText, pub(super) images: HashMap, diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index b7163c0e58..200ec93673 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -269,6 +269,12 @@ impl TextLine { } } + /// Horizontal type whose characters are all read glyph by glyph from the font's mappings (none + /// from the file's own ActualText). + pub fn mapped(&self) -> bool { + self.upright.is_none() && !self.column && !self.authored.contains(&true) + } + /// The paint the last glyph is filled with. pub fn last_fill(&self) -> Option<&Paint> { self.runs.last()?.0.fill.as_ref() diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 234f4b8a8d..3f74dd9d28 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -80,8 +80,8 @@ pub use file_info::{FileInfo, file_info}; #[cfg(any(test, feature = "read-count"))] pub use import::imports_of; pub use import::{ - INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, holds_outlines, - import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, + INVISIBLE_TEXT_NOTE, OFF_ARTBOARD_NOTE, OUTLINED_TEXT_NOTE, PAST_PAGE_NOTE, RESHAPED_TEXT_NOTE, UNMATCHED_GLYPHS_NOTE, UNNAMED_CHARACTERS_NOTE, + holds_outlines, import, import_opened, import_with_report, is_glyph_outlines, is_outlines_note, }; #[cfg(any(test, feature = "read-count"))] pub use pages::reads_of; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d8238d8038..4eeed94476 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -44,6 +44,7 @@ mod printadvanced; mod reader_compat; mod reader_streaming; mod realtext; +mod reshaped; mod settings; pub(super) mod stroke; mod strokeout; diff --git a/crates/pdf/src/tests/reshaped.rs b/crates/pdf/src/tests/reshaped.rs new file mode 100644 index 0000000000..060eca40b7 --- /dev/null +++ b/crates/pdf/src/tests/reshaped.rs @@ -0,0 +1,119 @@ +//! #5235: type in a script whose letters reorder as they are set (here Devanagari) opens as type +//! only when that type draws what the file drew; otherwise the file's glyphs stay, as outlines. +//! The file is written from scratch from the PDF specification (ISO 32000-1 §9.7, composite +//! fonts), its font program a test font built in code over Source Sans 3 +//! ([`vectorcraft_text::test_fonts::matra_font`]): Ka draws Source Sans 3's `a` and the vowel sign +//! I, set before its consonant, draws its `i`. + +use vectorcraft_doc::{Document, NodeKind}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::FontDb; +use vectorcraft_text::test_fonts::{MATRA_FAMILY, matra_font}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const KA: char = 'क'; +const SIGN_I: char = '\u{093F}'; + +/// The test font's glyph of `c` (Source Sans 3's glyph of the Latin letter that draws it) and its +/// advance in thousandths of an em. +fn glyph(c: char) -> (u32, f64) { + let face = FontDb::global().face(MATRA_FAMILY, "Regular").expect("the test font is added"); + let g = face.glyph_for(c); + (g, (face.advance(g) * 1000.0 / face.units_per_em()).round()) +} + +/// A 300 × 200 pt page showing the glyphs of `drawn`, in that order, in the test font at 40 pt +/// from (20, 100): a Type 0 font (Identity-H, two-byte glyph ids) with the font program embedded +/// and a ToUnicode map naming each glyph's character. `sign_width`: the advance the file gives +/// the vowel sign, in thousandths of an em (`None`: the font's). +fn page(drawn: &[char], sign_width: Option) -> Vec { + FontDb::global().add_font(matra_font().expect("the test font builds")); + let program = matra_font().unwrap(); + let hex: String = program.iter().map(|b| format!("{b:02X}")).collect::() + ">"; + let n = first_extra(1); + let rows: Vec = [KA, SIGN_I].iter().map(|&c| format!("<{:04X}> <{:04X}>", glyph(c).0, c as u32)).collect(); + let width = |c| sign_width.filter(|_| c == SIGN_I).unwrap_or(glyph(c).1); + let widths: Vec = [KA, SIGN_I].iter().map(|&c| format!("{} [{}]", glyph(c).0, width(c))).collect(); + let objs = [ + stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", program.len()), &hex), + format!( + "<< /Type /FontDescriptor /FontName /MatratestSans-Regular /Flags 4 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {n} 0 R >>" + ), + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /MatratestSans-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + n + 1, + widths.join(" ") + ), + stream( + "", + &format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + rows.len(), + rows.join("\n") + ), + ), + ]; + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let resources = format!( + "/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >>", + n + 2, + n + 3 + ); + let shown: String = drawn.iter().map(|&c| format!("{:04X}", glyph(c).0)).collect(); + let content = format!("BT /F1 40 Tf 20 100 Td <{shown}> Tj ET"); + pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None) +} + +fn open(bytes: &[u8]) -> ImportReport { + import_with_report(bytes, &ImportOptions { text_as: TextAs::Text, ..Default::default() }).unwrap() +} + +fn texts(d: &Document) -> Vec { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push(t.plain_text()); + } + }); + v +} + +fn outlines(d: &Document) -> usize { + let mut n = 0; + d.walk(|node| n += usize::from(is_glyph_outlines(node))); + n +} + +/// The file draws ककि as it is set: Ka, then the vowel sign before the Ka it follows in reading +/// order. Read glyph by glyph that is Ka, the sign, Ka, which reads किक: set as type, the sign +/// would move in front of the first Ka. The file's three glyphs stay as they are drawn. +#[test] +fn a_syllable_drawn_out_of_reading_order_keeps_the_files_glyphs() { + let report = open(&page(&[KA, SIGN_I, KA], None)); + assert_eq!(texts(&report.document), [""; 0], "no type that draws something else"); + assert_eq!(outlines(&report.document), 1, "the glyphs, as one path"); + assert!(report.warnings.iter().any(|w| w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); + assert!(report.warnings.iter().all(|w| !is_outlines_note(w) || w == RESHAPED_TEXT_NOTE), "{:?}", report.warnings); +} + +/// Letters the file draws in reading order make type that draws the same: they stay type. +#[test] +fn letters_drawn_in_reading_order_stay_type() { + let report = open(&page(&[KA, KA], None)); + assert_eq!(texts(&report.document), [format!("{KA}{KA}")]); + assert_eq!(outlines(&report.document), 0); + assert!(!report.warnings.iter().any(|w| is_outlines_note(w)), "{:?}", report.warnings); +} + +/// A mark the file gives no advance (as fonts do for the signs they set over a letter) belongs to +/// the line of its letter in a font written horizontally: it is not a glyph of vertical type, set +/// as an object of its own. Here the line it stays on then reads कि, whose sign type would set +/// before the Ka, so the line keeps the file's glyphs, the mark with them. +#[test] +fn a_mark_without_an_advance_stays_with_its_line() { + let report = open(&page(&[KA, SIGN_I], Some(0.0))); + assert_eq!(texts(&report.document), [""; 0], "the mark is no type of its own"); + assert_eq!(outlines(&report.document), 1, "both glyphs, as one path"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 89a048bd58..b908232cbf 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; 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; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -340,6 +340,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. |