Merge pull request #6906 from ruchirguitar/fix/pdf-indic-text-5235

PDF import: Indic type that would not draw as the file does keeps the file's glyphs (#5235)
This commit is contained in:
BFlat
2026-11-12 23:52:49 +07:00
committed by GitHub
11 changed files with 282 additions and 17 deletions
+3 -2
View File
@@ -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};
+80 -9
View File
@@ -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<StrokeProps>)>,
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()
{
+2 -1
View File
@@ -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));
+10 -2
View File
@@ -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 `<Text Outlines>`)?
+53
View File
@@ -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<Rect> =
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,
}
})
}
+4
View File
@@ -81,6 +81,10 @@ pub(super) struct Builder<'p> {
pub(super) page: Rect,
pub(super) glyphs: Option<GlyphRun>,
pub(super) text: Option<TextLine>,
/// 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<GlyphRun>,
pub(super) text_as: TextAs,
pub(super) actual: super::actual_text::ActualText,
pub(super) images: HashMap<String, ImageBlob>,
+6
View File
@@ -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()
+2 -2
View File
@@ -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;
+1
View File
@@ -44,6 +44,7 @@ mod printadvanced;
mod reader_compat;
mod reader_streaming;
mod realtext;
mod reshaped;
mod settings;
mod spread;
pub(super) mod stroke;
+119
View File
@@ -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<f64>) -> Vec<u8> {
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::<String>() + ">";
let n = first_extra(1);
let rows: Vec<String> = [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<String> = [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> <FFFF> 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<String> {
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");
}
+2 -1
View File
@@ -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
@@ -343,6 +343,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-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. Scores and estimates 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). |