Merge pull request #2808 from dotCooCoo/fix/ai-import-covered-type-spells-text

.ai import: page type over a text object is left out only when that object's text has its letters
This commit is contained in:
BFlat
2026-10-18 13:17:47 +07:00
committed by GitHub
4 changed files with 126 additions and 9 deletions
+5
View File
@@ -550,6 +550,11 @@ impl Story {
self.text.len()
}
/// The story's characters, with `\r` ending paragraphs and `\x03` ending lines.
pub(super) fn text(&self) -> &str {
&self.text
}
/// The point frame's matrix (the first frame's, when it is one), else the identity.
fn matrix(&self) -> [f64; 6] {
match self.frames.first() {
+40 -8
View File
@@ -473,11 +473,17 @@ fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
});
}
/// Do the regions of made type cover most of `object`?
fn covered(object: &[Arc<Node>], content: &[Content]) -> bool {
/// Do the regions of made type in the slots that `keep` takes cover most of `object`?
fn covered(object: &[Arc<Node>], content: &[Content], keep: impl Fn(usize) -> bool) -> bool {
let Some(r) = extent(object) else { return false };
let inside: f64 =
content.iter().filter_map(|c| if let Content::Made(_, Some(region), ..) = c { Some(region.intersect(r).area()) } else { None }).sum();
let inside: f64 = content
.iter()
.enumerate()
.filter_map(|(k, c)| match c {
Content::Made(_, Some(region), ..) => Some(region.intersect(r).area()).filter(|a| *a > 0.0 && keep(k)),
_ => None,
})
.sum();
inside >= 0.5 * r.area().max(1e-9)
}
@@ -570,7 +576,7 @@ fn plan(
// The file's text document is trusted where it agrees with the page about something (its lines
// start where the page's type does, or the page has type where it lays type out), or where the
// page has no type to disagree with.
let in_region = |object: &[Arc<Node>]| covered(object, &content);
let in_region = |object: &[Arc<Node>]| covered(object, &content, |_| true);
let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o));
if !trusted {
content.iter_mut().for_each(|c| *c = Content::Empty);
@@ -589,8 +595,33 @@ fn plan(
.is_some_and(|l| !words.is_empty() && words.iter().all(|w| l.iter().any(|(text, _)| letters(text).contains(w.as_str()))))
})
};
// The letters of each slot's story, in lower case.
let spelled: Vec<String> = stories.iter().map(|s| s.as_ref().map(|s| letters(s.text()).to_ascii_lowercase()).unwrap_or_default()).collect();
// For each slot: is its made type where `object` is, with the letters of one of `object`'s texts in
// its story? All are false unless such slots have the letters of every text between them.
let spelling = |object: &[Arc<Node>], content: &[Content]| -> Vec<bool> {
let r = extent(object);
let words: Vec<String> = object
.iter()
.filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(letters(&t.plain_text()).to_ascii_lowercase()) } else { None })
.filter(|w| !w.is_empty())
.collect();
let has = |k: usize| spelled.get(k).is_some_and(|s| words.is_empty() || words.iter().any(|w| s.contains(w.as_str())));
let near: Vec<bool> = content
.iter()
.enumerate()
.map(|(k, c)| match (c, r) {
(Content::Made(_, Some(region), ..), Some(r)) => region.intersect(r).area() > 0.0 && has(k),
_ => false,
})
.collect();
let every = words.iter().all(|w| near.iter().zip(&spelled).any(|(n, s)| *n && s.contains(w.as_str())));
if every { near } else { vec![] }
};
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
for (j, object) in shown.iter().enumerate() {
let spells = spelling(object, &content);
let spells = |k: usize| spells.get(k).copied().unwrap_or(false);
match owner.get(j).copied().flatten().filter(|_| trusted) {
// The type of a text object the layers hide: the layers have it, hidden, so the page's
// isn't another text object's.
@@ -601,12 +632,13 @@ fn plan(
Some(c) => *c = Content::Page(object.clone()),
None => {}
},
// What lies where made type is, is that type as the page draws it.
None if covered(object, &content) => {
// What lies where made type is and has that type's letters is that type as the page draws it.
None if covered(object, &content, spells) => {
let r = extent(object).unwrap_or(Rect::ZERO);
for c in &mut content {
for (k, c) in content.iter_mut().enumerate() {
if let Content::Made(_, Some(region), seen, _) = c
&& !region.intersect(r).is_zero_area()
&& spells(k)
{
*seen = true;
}
+80
View File
@@ -643,6 +643,86 @@ fn type_of_the_page_that_no_story_places_stays_with_the_nearest_text_object() {
assert!(all.contains(&"Extra".to_string()), "{all:?} {:?}", r.warnings);
}
/// Page type of no text object that lies where made type is, but has other letters, is not that type:
/// it stays, with the nearest text object.
#[test]
fn type_of_the_page_near_made_type_with_other_letters_stays() {
// "Extra" is 11.5 below the line of "Hi there", well inside the box round that line.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show 58.5 50 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Hi there", "Extra"], "{:?}", r.warnings);
assert!(r.warnings.iter().any(|w| w.contains("with another's")), "{:?}", r.warnings);
}
/// Page type of no text object that lies where made type is and has its letters, in any case, is that
/// type as the page draws it: the made type stays, and the page's is left out.
#[test]
fn type_of_the_page_with_the_letters_of_made_type_where_it_lies_is_that_type() {
// The page draws the line 3 points below where the story starts it, so no line of the story
// owns it.
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
for drawn in ["Hi there", "HI THERE", "there"] {
let page = format!("0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto ({drawn}) show");
let r = import(&eps(&page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push((t.plain_text(), t.xf.as_coeffs()[5]));
}
});
assert_eq!(all, [("Hi there".to_string(), 38.5)], "{drawn}: {:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{drawn}: {:?}", r.warnings);
}
}
/// Page type of no text object that lies where the made type of two text objects is, with the
/// letters of one of them in each of its pieces, is the type of both as the page draws it.
#[test]
fn type_of_the_page_with_the_letters_of_two_made_types_where_it_lies_is_their_type() {
// The two stories start 16.5 apart on one line, and the page draws both 3 points below it: its
// two pieces are one object, which no line of either story owns.
let doc = text_document(&[("Hi\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("there\r", 0, (8216.5, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &(text_object(0) + &text_object(1))), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 58.5 moveto (Hi) show 75 58.5 moveto (there) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push((t.plain_text(), t.xf.as_coeffs()[5]));
}
});
assert_eq!(all, [("Hi".to_string(), 38.5), ("there".to_string(), 38.5)], "{:?}", r.warnings);
assert!(!r.warnings.iter().any(|w| w.contains("doesn't draw") || w.contains("with another's")), "{:?}", r.warnings);
}
/// The file's text document is trusted where the page has type where it lays type out, whatever
/// letters that type has: its type is weighed against the page.
#[test]
fn page_type_where_made_type_is_trusts_the_text_document_whatever_its_letters() {
let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 50 moveto (Extra) show";
let r = import(&eps(page, &art)).unwrap();
let mut all = vec![];
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
all.push(t.plain_text());
}
});
assert_eq!(all, ["Extra"], "{:?}", r.warnings);
// "Hi there" was made from the text document and left out because the page doesn't draw it.
assert!(r.warnings.iter().any(|w| w.contains("doesn't draw")), "{:?}", r.warnings);
}
#[test]
fn type_the_page_draws_of_a_text_object_on_a_hidden_layer_stays_hidden() {
// A file's page may draw what its layers have hidden since: story 1's type is on the page at
+1 -1
View File
@@ -687,7 +687,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`).
In a `.ai` or EPS opened from its editing data, type has its text object's opacity and blend mode, whether it is made from the file's text document or taken from the page. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, that type is left out only when, for each piece of it, one of those text objects has the piece's Latin letters and digits in its text, in capitals or not; other such type is kept as the page's other type of no text object is.
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/text_layout.rs`).
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`). Such a `.ai` opens without the PDF part's warnings that text was kept as outlines when no outlined glyphs come in, and a warning counts the text objects that hold the page's type next to glyphs the page has only as outlines, which don't come in (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). Where the art of a `.ai` or EPS opened from its editing data differs a little from the file's page, the warning gives the share of the page that differs away from type. When that share is 0.2% or less, the warning names the type and gives the share of the page that differs where the type is (`crates/eps/src/import/tests_native.rs`).