PDF import: outlined Indic glyphs keep their own stroke and transparency (#5235) (#6957)

* PDF import: outlined Indic glyphs keep their own stroke and transparency (#5235)

Follow-up to #6906, for two review findings on how a line kept as
outlines is joined into paths:

- Glyphs were joined when their stroke widths matched; dash, cap, join
  and miter limit were ignored, so a dashed glyph took the stroke of the
  solid one before it. They join only when the whole stroke matches.
- Translucent glyphs were joined into one fill, where overlapping marks
  paint once instead of compositing as the file draws them. Only opaque
  glyphs join.

Also records the fix in the PDF row of docs/target-app-parity.md and in
docs/roadmap.md, as AGENTS.md asks.

Tests: outlined_glyphs_stroked_differently_stay_apart and
outlined_translucent_glyphs_stay_apart (both fail without the change).

* PDF import: outlined Indic glyphs under a soft mask stay apart; check kept geometry (#5235)

Review follow-ups on #6957 and #6906:

- Glyphs kept as outlines under a soft mask were joined into one path,
  so the mask was applied once to all of them where the file applies it
  to each glyph. They now join only when no soft mask is active.
- The fallback tests counted outline nodes only. A new test compares the
  kept outlines (element count and bounding box) with the same file
  opened as outlines, so a dropped or moved glyph fails it.

Tests: outlined_glyphs_under_a_soft_mask_stay_apart (fails without the
change) and kept_glyphs_are_where_the_file_draws_them.

* PDF import: mask outlined Indic glyphs one by one under a soft mask (#5235)

Review follow-up on #6957. Keeping glyphs out of one shared path was not
enough: each path still went into the soft mask's queue and
flush_masked wrapped them all in one masked group, so a mask that is not
constant was applied once to every glyph, where overlapping glyphs
composite one by one in the file.

With a soft mask active the queued art is now drained before the first
glyph and after each one, so every glyph gets a mask of its own.

Test: outlined_glyphs_under_a_varying_soft_mask_are_masked_one_by_one
checks that each outlined glyph carries its own mask (0 of 3 without
the change).

* #6957: clarify soft-mask exception in glyph grouping docs

---------

Co-authored-by: ruchirguitar <info@jeevantirth.org>
This commit is contained in:
ruchirguitar
2026-11-14 03:26:20 +01:00
committed by GitHub
co-authored by ruchirguitar
parent 9fcfc81cc2
commit 30eb14d7dc
5 changed files with 152 additions and 13 deletions
+23 -9
View File
@@ -4,7 +4,7 @@ use super::entry::{
UNNAMED_CHARACTERS_NOTE, max_objects,
};
use super::reshaped;
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, stroke_layer};
use super::state::{Builder, Frame, FrameKind, GlyphRun, Saved, bounds, grouped, line_width, mean_scale, paints, round3, same_stroke, 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,
@@ -445,15 +445,23 @@ impl<'p> Builder<'p> {
if let Some((t, opacity, facts)) = line.finish() {
if checked && reshaped::reshapes(&t.plain_text()) && !reshaped::draws_as(&t, &facts.ink) {
outlined = true;
if self.mask.is_some() {
self.flush_glyphs();
self.flush_masked();
}
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
});
// Glyphs the file fills one by one composite where they overlap: only opaque
// ones, drawn alike down to the stroke, and not under a soft mask (which
// would be applied once to all of them), share a path.
let same = g.opacity >= 0.999
&& self.mask.is_none()
&& self.glyphs.as_ref().is_some_and(|r| {
r.paint == g.paint
&& r.opacity == g.opacity
&& r.overprint == g.overprint
&& same_stroke(r.stroke.as_ref(), g.stroke.as_ref())
&& (r.scale - g.scale).abs() < 1e-9
});
match &mut self.glyphs {
Some(r) if same => r.path.extend(g.path.iter()),
_ => {
@@ -461,6 +469,12 @@ impl<'p> Builder<'p> {
self.glyphs = Some(g);
}
}
// Under a soft mask each glyph is masked on its own: the art queued for the
// mask is drained at each glyph, or one group of them all takes it once.
if self.mask.is_some() {
self.flush_glyphs();
self.flush_masked();
}
}
self.warn(RESHAPED_TEXT_NOTE);
} else {
+17
View File
@@ -235,6 +235,23 @@ pub(super) fn line_width(p: &StrokeProps, scale: f64) -> f64 {
if p.line_width == 0.0 { HAIRLINE } else { p.line_width as f64 * scale }
}
/// Do strokes `a` and `b` draw alike: both unstroked, or the same width, caps, joins, miter limit
/// and dashes ([`stroke_layer`] takes all of them)?
pub(super) fn same_stroke(a: Option<&StrokeProps>, b: Option<&StrokeProps>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => {
a.line_width == b.line_width
&& a.line_cap == b.line_cap
&& a.line_join == b.line_join
&& a.miter_limit == b.miter_limit
&& a.dash_array == b.dash_array
&& a.dash_offset == b.dash_offset
}
_ => false,
}
}
pub(super) fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
let mut st = StrokeLayer::new(paint, line_width(p, scale));
st.opacity = opacity;
+105 -4
View File
@@ -5,6 +5,7 @@
//! ([`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 kurbo::{Rect, Shape};
use vectorcraft_doc::{Document, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use vectorcraft_text::FontDb;
@@ -29,6 +30,14 @@ fn glyph(c: char) -> (u32, f64) {
/// 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> {
page_with(|id| format!("[{}] TJ", drawn.iter().map(|&c| id(c)).collect::<String>()), "", &[], sign_width)
}
/// [`page`] showing glyphs with the text operators `ops` makes (after the position of the first
/// glyph) from a function giving a character's glyph id in hex, and `extra` more resources: a
/// graphics state, to set between glyphs or before them, with `objects` it refers to (the first
/// is object `first_extra(1) + 4`).
fn page_with(ops: impl Fn(&dyn Fn(char) -> String) -> String, extra: &str, objects: &[String], 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>() + ">";
@@ -55,14 +64,13 @@ fn page(drawn: &[char], sign_width: Option<f64>) -> Vec<u8> {
),
),
];
let refs: Vec<&str> = objs.iter().map(String::as_str).collect();
let refs: Vec<&str> = objs.iter().chain(objects).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 >> >>",
"/Font << /F1 << /Type /Font /Subtype /Type0 /BaseFont /MatratestSans-Regular /Encoding /Identity-H /DescendantFonts [{} 0 R] /ToUnicode {} 0 R >> >> {extra}",
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");
let content = format!("BT /F1 40 Tf 20 100 Td {} ET", ops(&|c| format!("<{:04X}>", glyph(c).0)));
pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, &content) }], &refs, None)
}
@@ -117,3 +125,96 @@ fn a_mark_without_an_advance_stays_with_its_line() {
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");
}
/// The file's glyphs are outlines of one path when drawn alike, and a path each when a stroke
/// property other than the width (here the dash) differs between them: the first glyph's would
/// otherwise be drawn on all.
#[test]
fn outlined_glyphs_stroked_differently_stay_apart() {
// Stroked (`1 Tr`) at the same width, one glyph solid and the next dashed.
let alike = page_with(|id| format!("1 Tr 2 w {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
let apart = page_with(|id| format!("1 Tr 2 w {} Tj [3 2] 0 d {} Tj [] 0 d {} Tj", id(KA), id(SIGN_I), id(KA)), "", &[], None);
assert_eq!(outlines(&open(&alike).document), 1, "drawn alike: one path");
assert_eq!(outlines(&open(&apart).document), 3, "solid, dashed, solid: a path each");
}
/// Translucent glyphs the file fills one by one composite where they overlap, which one fill of
/// their joined outlines does not do: each keeps a path of its own.
#[test]
fn outlined_translucent_glyphs_stay_apart() {
let bytes = page_with(|id| format!("/G gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), "/ExtGState << /G << /ca 0.5 >> >>", &[], None);
let report = open(&bytes);
assert_eq!(texts(&report.document), [""; 0]);
assert_eq!(outlines(&report.document), 3, "a path each");
}
/// Glyphs the file draws under a soft mask are not joined into one path: the mask would be applied
/// once to the whole, where the file applies it to each glyph.
#[test]
fn outlined_glyphs_under_a_soft_mask_stay_apart() {
// A luminosity mask (object `first_extra(1) + 4`) of mid gray over the page.
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>", "0.5 g 0 0 300 200 re f");
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
let report = open(&bytes);
assert_eq!(texts(&report.document), [""; 0]);
assert_eq!(outlines(&report.document), 3, "a path each");
}
/// What the glyph outlines of `d` draw: how many path elements, and the box round them all.
fn outline_geometry(d: &Document) -> (usize, Rect) {
let (mut elements, mut bounds) = (0, None::<Rect>);
d.walk(|n| {
if is_glyph_outlines(n)
&& let Some(p) = n.path_data()
{
let path = p.to_bezpath();
elements += path.elements().len();
let b = path.bounding_box();
bounds = Some(bounds.map_or(b, |u| u.union(b)));
}
});
(elements, bounds.unwrap_or(Rect::ZERO))
}
/// A line kept as outlines draws the glyphs where they are drawn when the file is opened as
/// outlines: none dropped or moved.
#[test]
fn kept_glyphs_are_where_the_file_draws_them() {
for (drawn, sign_width) in [(vec![KA, SIGN_I, KA], None), (vec![KA, SIGN_I], Some(0.0))] {
let bytes = page(&drawn, sign_width);
let kept = open(&bytes);
let all = import_with_report(&bytes, &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
assert_eq!(texts(&kept.document), [""; 0], "the line is kept as outlines");
let (kept, all) = (outline_geometry(&kept.document), outline_geometry(&all.document));
assert!(all.0 > 0, "the glyphs have outlines");
assert_eq!(kept.0, all.0, "every outline element is kept");
for (a, b) in [(kept.1.x0, all.1.x0), (kept.1.y0, all.1.y0), (kept.1.x1, all.1.x1), (kept.1.y1, all.1.y1)] {
assert!((a - b).abs() < 1e-6, "{:?} vs {:?}", kept.1, all.1);
}
}
}
/// How many glyph outlines carry a soft mask of their own.
fn masked_outlines(d: &Document) -> usize {
let mut n = 0;
d.walk(|node| n += usize::from(is_glyph_outlines(node) && node.extras().mask.is_some()));
n
}
/// A mask that isn't constant (dark on the left, light on the right) is a mask of its own on each
/// glyph, not one on a group of them: glyphs that overlap composite one by one, as the file draws
/// them.
#[test]
fn outlined_glyphs_under_a_varying_soft_mask_are_masked_one_by_one() {
let group = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 200] /Group << /S /Transparency /CS /DeviceGray >>",
"0.2 g 0 0 150 200 re f 0.9 g 150 0 150 200 re f",
);
let mask = format!("/ExtGState << /M << /SMask << /S /Luminosity /G {} 0 R >> >> >>", first_extra(1) + 4);
let bytes = page_with(|id| format!("/M gs {} Tj {} Tj {} Tj", id(KA), id(SIGN_I), id(KA)), &mask, &[group], None);
let report = open(&bytes);
assert_eq!(texts(&report.document), [""; 0]);
assert_eq!(outlines(&report.document), 3, "a path each");
assert_eq!(masked_outlines(&report.document), 3, "each path has its own mask");
}
+3
View File
@@ -10,6 +10,8 @@
>
> Numeric spinner update (2026-11-10): held mouse arrows repeat past the click timeout; scores and estimates unchanged (#6307).
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
## Feature submission evidence
M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged.
@@ -56,6 +58,7 @@ M7 type: Touch Type moves a right-to-left character along its line and up from i
| 2026-11-13 | minor | PDF import: glyphs inside a marked-content span with an empty `ActualText` come in as outlines in their fill and stroke instead of being left out, adding no text (#6971; `empty_actual_text_keeps_the_glyphs_art` in `crates/pdf/src/tests/actual_text.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | M4: PDF import (#5235, #6906): 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 (opaque, alike-stroked ones share a path except under a soft mask, where each glyph keeps its own path and mask; translucent ones keep their own). Scores and estimates unchanged. Evidence: `crates/pdf/src/tests/reshaped.rs`. |
| 2026-11-10 | minor | M13 / G6: MCP catalogue (#6460): headless lists UI commands, eight prompts, 33 tools, effect vs command ids; scores and estimates unchanged. |
| 2026-11-08 | minor | Character Styles panel: a double-click on a user style's row opens its Character Style Options (`ui.charStyleOptions`); the dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. 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. |
+4
View File
@@ -12,6 +12,8 @@
>
> **Flyout update:** 2026-11-06 — resizable icon-strip flyouts and bottom-aligned panel footers (#5713); scores and estimates unchanged.
> **PDF import status (2026-11-14, #6957):** Outlined Indic glyphs retain their full stroke properties; translucent glyphs stay separate, and a soft mask attaches to each glyph separately. Scores and estimates unchanged.
## Feature submission evidence
EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md).
@@ -60,6 +62,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard
| 2026-11-13 | minor | Library panels (swatch, Color Book, brush, symbol and graphic style libraries): in a panel made taller the list fills the space above the footer instead of scrolling inside 220 or 280 pt (#2992; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/library_panel/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Swatches: in a panel made taller the swatch list fills the space above the footer instead of scrolling inside 150 or 200 pt (#1638; `the_list_fills_a_tall_panel_above_its_footer` in `crates/ui-egui/src/panels/swatches/tests.rs`). Scores and estimates unchanged. |
| 2026-11-13 | minor | Swatches panel: its menu's Show Recent Colors hides and shows the panel's Recent Colors row, checked by default and kept between sessions apart from the Color panel's (#6617); scores unchanged. |
| 2026-11-13 | minor | M4: PDF import (#5235, #6906): 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 (opaque, alike-stroked ones share a path except under a soft mask, where each glyph keeps its own path and mask; translucent ones keep their own). Scores and estimates unchanged. Evidence: `crates/pdf/src/tests/reshaped.rs`. |
| 2026-11-09 | minor | Stroke panel: Line Arrow, a 41st generated arrowhead: two arms of the head's weight (stroke weight × arrowhead scale, at least 0.25 pt: the line's weight at 100 % for lines of 0.25 pt or more) at right angles, a sharp tip and square-cut ends, twice as wide as the other heads; bounds and clicks follow each head's own reach (`stroke.set` `LineArrow`, #5421); scores and estimates unchanged |
| 2026-11-09 | minor | Canvas: a 3D effect (Extrude & Bevel, Inflate, Rotate, Revolve) changed on type or a group draws again where its art was and is, measured by that art; the canvas measures through its renderer and draws the part with the art it made to measure it. Exports and Object › Rasterize take the solid of 3D type. Fit to Artwork Bounds and per-artboard SVG export measure every artboard through one renderer, building each solid once. A 3D effect on a fill or stroke that builds nothing (Revolve after Roughen) no longer overflows the stack on the canvas, in Object › Rasterize and in raster and EPS exports: the fill or stroke draws as without it. Tests `tests::changed::a_3d_effect_changed_on_type_or_a_group_changes_where_its_solid_was`, `fx::tests::a_renderer_measures_the_art_on_each_artboard_building_each_solid_once`, `canvas::tests::an_edit_to_a_3d_effect_redraws_with_the_art_measured`, `fx::tests::a_3d_effect_that_builds_nothing_draws_the_item_as_without_it`. Scores and estimates unchanged. |
| 2026-11-08 | minor | Style panels: a double-click on a user character style's row opens its Character Style Options (`ui.charStyleOptions`), and on a paragraph style's row its Paragraph Style Options (rename); the Character Style Options dialog keeps a fixed width and scrolls its sections in a shorter window, so OK and Cancel stay on screen. Tests `panels::text_styles::tests::double_clicking_a_character_style_row_opens_its_options` and `dialogs::char_style_options::tests::the_sections_scroll_in_a_window_shorter_than_the_general_list`. Scores and estimates unchanged. |
@@ -568,6 +571,7 @@ since 2026-10-01.
- **Save refusal (#6146):** ordinary File > Save to its own location offers Save As after a terminal permission/read-only refusal, suggesting the original name and format, preserving options for the unchanged format and opening the chosen format’s Save options on a change. Cancel keeps the failed-save state. Busy pickers, recovery-copy errors, other failures, explicit destinations and Close/Quit keep the existing dialog. Engine, UI and MCP regression cases are in `cmd/fileio/tests_readonly.rs`, `io/tests_read_only_save.rs` and MCP `tests/fileio.rs`; focused UI regressions passed; engine regression execution and native panel acceptance pending. Scores and hours unchanged.
- **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h.
- **PDF Indic type (#5235, #6906):** `crates/pdf/src/tests/reshaped.rs` covers a vowel sign the file draws before its consonant (the line keeps the file's glyphs as outlines, with a note), text in reading order (stays type), a mark with no advance (stays on its line, not vertical type of its own), outlined glyphs stroked with different dashes and translucent outlined glyphs (each keeps its own path). Synthetic font only; one real Gujarati file checked by eye on page 1. Indic type in an uninstalled font keeps the file's glyphs as outlines; reordering signs so more lines stay editable is not done. Scores and hours stay at 75% / 10-20 h.
- **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged.