mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
A pattern's matrix maps pattern space to the default space of the content stream that uses it (ISO 32000-2 §8.7.2). Inside a Form XObject that is the form's space at the Do; drawn inline on the page it became the page's, so a form moved by its /Matrix or a cm before the Do lost its patterns' place: tiles out of phase or at the wrong size, a shading outside its box. Each pattern the form selects with scn/SCN is now drawn from a copy whose /Matrix is the pattern's own, then the form's /Matrix, then the CTM at the Do. The form's own pattern object is unchanged for its other placements, a pattern selected twice is copied once, and a pattern that can't be copied that way (unreadable matrix, non-finite result, a tiling stream past the size limit, more than 256 patterns) is refused rather than drawn elsewhere. Nested forms and patterns inside patterns keep their own parents. Tests: ungroup_carries_the_placement_into_the_patterns_it_selects (edit) and ungrouping_keeps_patterns_where_the_forms_placement_put_them (automation, four synthetic cases rendered before and after save/reopen), both seen failing with the fix switched off. The same four cases were checked in Acrobat Pro DC, where the unfixed output drew differently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC
This commit is contained in:
1 parent
5edcb14695
commit
54b450da0e
5 files changed
+240
-17
No files matched your search
@@ -10273,3 +10273,68 @@ fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back");
|
||||
}
|
||||
|
||||
/// One 300 × 300 page that draws form `Fm1` with `page_do`; the form (with `matrix`) fills with
|
||||
/// pattern `/P1` (`pattern`) in `content`, and a red outline on the page marks where it lands.
|
||||
fn pattern_in_a_form_pdf(page_do: &str, matrix: &str, content: &str, pattern: &str) -> Vec<u8> {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_string(),
|
||||
stream_object("<<", &format!("{page_do} 1 0 0 RG 1 w 60 60 180 180 re S")),
|
||||
stream_object(&format!("<< /Type /XObject /Subtype /Form /BBox [0 0 300 300] {matrix} /Resources << /Pattern << /P1 6 0 R >> >>"), content),
|
||||
pattern.to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that
|
||||
/// uses it (ISO 32000-2 §8.7.2), so a pattern filled inside a form moved by its `/Matrix` or by a
|
||||
/// `cm` before the `Do` must stay where it was when the form is drawn inline. Each case draws
|
||||
/// differently in Acrobat when the placement is lost (tiles out of phase or half size, the
|
||||
/// shading outside its box); ungrouped, every one paints the same pixels.
|
||||
#[test]
|
||||
fn ungrouping_keeps_patterns_where_the_forms_placement_put_them() {
|
||||
let tiling = stream_object(
|
||||
"<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << >>",
|
||||
"0 0 0 rg 0 0 10 10 re f 10 10 10 10 re f",
|
||||
);
|
||||
let shading = "<< /Type /Pattern /PatternType 2 /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> /Extend [false false] >> >>";
|
||||
let cases = [
|
||||
("no placement", "q /Fm1 Do Q", "", "/Pattern cs /P1 scn 60 60 180 180 re f", tiling.as_str()),
|
||||
("form Matrix, half a tile", "q /Fm1 Do Q", "/Matrix [1 0 0 1 10 0]", "/Pattern cs /P1 scn 50 60 180 180 re f", tiling.as_str()),
|
||||
("cm doubling the form", "q 2 0 0 2 0 0 cm /Fm1 Do Q", "", "/Pattern cs /P1 scn 30 30 90 90 re f", tiling.as_str()),
|
||||
("shading moved by the form Matrix", "q /Fm1 Do Q", "/Matrix [1 0 0 1 100 100]", "/Pattern cs /P1 scn 0 0 100 100 re f", shading),
|
||||
];
|
||||
for (what, page_do, matrix, content, pattern) in cases {
|
||||
let dir = workdir("ungroup_patterns");
|
||||
std::fs::write(dir.join("p.pdf"), pattern_in_a_form_pdf(page_do, matrix, content, pattern)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "p.pdf" }))["doc"].as_u64().unwrap();
|
||||
let (w, h, before) = a.render_pixels(doc, 1, 1.0).unwrap();
|
||||
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].as_u64().unwrap();
|
||||
let (w2, h2, after) = a.render_pixels(saved, 1, 1.0).unwrap();
|
||||
assert_eq!((w, h), (w2, h2), "{what}");
|
||||
let differing = before.as_ref().as_chunks::<4>().0.iter().zip(after.as_ref().as_chunks::<4>().0).filter(|(p, q)| p != q).count();
|
||||
assert_eq!(differing, 0, "{what}: ungrouping draws the same pixels, saved and reopened");
|
||||
}
|
||||
}
|
||||
|
||||
/// An indirect stream object: `dict` (without its closing `>>`) and `data`.
|
||||
fn stream_object(dict: &str, data: &str) -> String {
|
||||
format!("{dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
|
||||
}
|
||||
@@ -502,7 +502,14 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
}
|
||||
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
|
||||
let (inline, merged) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent)?;
|
||||
let (inline, mut merged, patterns) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, Matrix(img.matrix))?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, copy) in patterns {
|
||||
table.set(name, Object::Ref(doc.add(copy)));
|
||||
}
|
||||
merged.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
resources = Some(merged);
|
||||
inline
|
||||
}
|
||||
|
||||
@@ -8799,10 +8799,16 @@ fn ungroup_renames_every_kind_of_resource() {
|
||||
for cat in categories {
|
||||
let hits: Vec<_> = used.iter().filter(|(c, _)| *c == cat).collect();
|
||||
assert!(!hits.is_empty(), "{cat} is used: {used:?}");
|
||||
assert!(
|
||||
hits.iter().any(|(_, n)| lookup(cat, n) == Some(tag(&format!("form {cat}")))),
|
||||
"{cat}: the inline content names the form's object: {hits:?}"
|
||||
);
|
||||
// The form is placed by its Matrix [2 0 0 2 0 0] after the page's `1 0 0 1 40 80 cm`, so
|
||||
// its pattern is drawn from a copy that carries that placement (ISO 32000-2 §8.7.2).
|
||||
let want = match tag(&format!("form {cat}")) {
|
||||
Object::Dict(mut d) if cat == "Pattern" => {
|
||||
d.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 40, 80].map(Object::Int).to_vec()));
|
||||
Object::Dict(d)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
assert!(hits.iter().any(|(_, n)| lookup(cat, n) == Some(want.clone())), "{cat}: the inline content names the form's object: {hits:?}");
|
||||
}
|
||||
assert!(
|
||||
used.iter().filter(|(c, _)| *c == "ColorSpace").all(|(_, n)| lookup("ColorSpace", n) == Some(tag("form ColorSpace"))),
|
||||
@@ -8811,6 +8817,57 @@ fn ungroup_renames_every_kind_of_resource() {
|
||||
assert_eq!(lookup("ExtGState", b"G"), Some(tag("page ExtGState")), "the page's own G is kept");
|
||||
}
|
||||
|
||||
/// #3067 review: a pattern's matrix maps pattern space to the default space of the stream that
|
||||
/// uses it (ISO 32000-2 §8.7.2), so drawn from the page a form's pattern would lose the form's
|
||||
/// placement. Ungroup draws it from a copy whose `/Matrix` carries that placement. A pattern
|
||||
/// selected twice is copied once, the form keeps its own for the other placement, and a pattern
|
||||
/// whose matrix can't be read is refused rather than drawn somewhere else.
|
||||
#[test]
|
||||
fn ungroup_carries_the_placement_into_the_patterns_it_selects() {
|
||||
let pattern = |matrix: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"PatternType".to_vec(), Object::Int(2));
|
||||
d.set(b"Shading".to_vec(), Object::Dict(Dict::new()));
|
||||
d.set(b"Matrix".to_vec(), matrix);
|
||||
Object::Dict(d)
|
||||
};
|
||||
let form_with = |p: Object| {
|
||||
let mut patterns = Dict::new();
|
||||
patterns.set(b"P".to_vec(), p);
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Pattern".to_vec(), Object::Dict(patterns));
|
||||
page_with_form(&[("Resources", Object::Dict(res))], b"/Pattern cs /P scn 0 0 10 10 re f /P scn 0 0 5 5 re f", "", None)
|
||||
};
|
||||
let own = pattern(Object::Array([1, 0, 0, 1, 3, 4].map(Object::Int).to_vec()));
|
||||
let (mut doc, artwork) = form_with(own.clone());
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let page = pdfcraft_model::pages(&doc).into_iter().next().unwrap();
|
||||
let res = doc.resolve(page.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let table = doc.resolve(res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one content stream") };
|
||||
let selected: Vec<Vec<u8>> =
|
||||
pdfcraft_content::parse(&s.decoded().unwrap()).ops.iter().filter(|o| o.is("scn")).filter_map(|o| o.name(0).map(<[u8]>::to_vec)).collect();
|
||||
assert_eq!(selected.len(), 2, "{selected:?}");
|
||||
assert!(selected[0] == selected[1] && selected[0] != b"P", "both selections use one copy: {selected:?}");
|
||||
let copy = doc.resolve(table.get(&selected[0]).unwrap()).as_dict().cloned().unwrap();
|
||||
// [1 0 0 1 3 4], then the form's Matrix [2 0 0 2 0 0], then the page's `1 0 0 1 40 80 cm`.
|
||||
let matrix = copy.get(b"Matrix").and_then(Object::as_array).and_then(|a| pdfcraft_content::Matrix::from_operands(a));
|
||||
assert_eq!(matrix.map(|m| m.0), Some([2.0, 0.0, 0.0, 2.0, 46.0, 88.0]));
|
||||
assert_eq!(table.iter().filter(|(k, _)| k.starts_with(b"PCUgPat")).count(), 1, "one copy");
|
||||
let Object::Stream(form) = doc.get(artwork).as_ref().clone() else { panic!("the form") };
|
||||
let form_res = doc.resolve(form.dict.get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let form_patterns = doc.resolve(form_res.get(b"Pattern").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(form_patterns.get(b"P"), Some(&own), "the form keeps its own pattern for its other placement");
|
||||
|
||||
let (mut doc, _) =
|
||||
form_with(pattern(Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0), Object::Int(1), Object::name("Bad"), Object::Int(4)])));
|
||||
match images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup) {
|
||||
Err(EditError::Invalid(m)) => assert!(m.contains("position can't be kept"), "{m}"),
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ungroup draws the form where the `Do` drew it and only inside its bounding box: the inline
|
||||
/// content starts `q <Matrix> cm <BBox> re W n`, a form without a Matrix gets no `cm`, and the
|
||||
/// page's other content is unchanged around it.
|
||||
|
||||
+104
-10
@@ -1,8 +1,8 @@
|
||||
//! Edit a PDF ▸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005).
|
||||
//! Edit a PDF â–¸ Ungroup: draw a Form XObject's content in place of its `Do` (#2964, #3005).
|
||||
//!
|
||||
//! Text inside grouped Form artwork can't be edited, because editing rewrites only the page's own
|
||||
//! content streams. Ungrouping copies the form's content into the page at the same place,
|
||||
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
|
||||
//! wrapped as `q <Matrix> cm <BBox> re W n … Q`, with the form's resources merged into a copy of
|
||||
//! the page's under names that don't clash. Its text then edits like any other. The form object
|
||||
//! itself is left alone, so another page or placement that draws it is unchanged.
|
||||
//!
|
||||
@@ -11,7 +11,12 @@
|
||||
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
|
||||
//! mask. Those are refused rather than changed, as is tagged form content (its structure link
|
||||
//! would be lost) and content whose marked content or text objects don't balance (inlined, they
|
||||
//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//!
|
||||
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
|
||||
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
|
||||
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
|
||||
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -24,16 +29,25 @@ use crate::EditError;
|
||||
/// any real form, and a bomb must not be copied into the page.
|
||||
const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
|
||||
|
||||
/// The ops that draw form `name` (from the page's `resources`) inline, and the page resources
|
||||
/// with the form's merged in. `translucent` says whether the graphics state at the `Do` is
|
||||
/// translucent, blended or soft-masked, which a transparency group composites as a whole.
|
||||
pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translucent: bool) -> Result<(Vec<Op>, Dict), EditError> {
|
||||
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
|
||||
/// the form's merged in, and the pattern copies to add to the document, each under the name in
|
||||
/// the merged `/Pattern` dictionary that the ops already use.
|
||||
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
|
||||
|
||||
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
|
||||
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
|
||||
/// whole. `placement` maps the form's space to the page's default space at the `Do` (its
|
||||
/// `/Matrix`, then the CTM there).
|
||||
pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translucent: bool, placement: Matrix) -> Result<Ungrouped, EditError> {
|
||||
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
|
||||
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
|
||||
@@ -189,6 +203,56 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu
|
||||
return Err(refuse("its content leaves marked content or a text object open"));
|
||||
}
|
||||
|
||||
// Patterns keep their place relative to the shapes they fill: each one the form selects is
|
||||
// drawn from a copy that also carries the placement. Nested forms and patterns inside
|
||||
// patterns keep their own parents, so only this stream's selections change.
|
||||
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
|
||||
if placement != Matrix::IDENTITY {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut moved: Renames = Vec::new();
|
||||
let mut n = 0u32;
|
||||
for op in &mut ops {
|
||||
if !(op.is("scn") || op.is("SCN")) {
|
||||
continue;
|
||||
}
|
||||
let Some(i) = op.operands.len().checked_sub(1) else { continue };
|
||||
let Some(Object::Name(from)) = op.operands.get(i) else { continue };
|
||||
// A name the resources don't have selects nothing, inline or not.
|
||||
let Some(entry) = table.get(from).cloned() else { continue };
|
||||
let to = match moved.iter().find(|(f, _)| f == from) {
|
||||
Some((_, to)) => to.clone(),
|
||||
None => {
|
||||
if moved.len() >= MAX_MOVED_PATTERNS {
|
||||
return Err(refuse("it paints with too many patterns"));
|
||||
}
|
||||
let copy =
|
||||
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUgPat{n}").into_bytes();
|
||||
if table.get(&candidate).is_none() {
|
||||
break candidate;
|
||||
}
|
||||
if n == u32::MAX {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
};
|
||||
// Reserved here; the caller replaces it with the copy's reference.
|
||||
table.set(fresh.clone(), Object::Null);
|
||||
patterns.push((fresh.clone(), copy));
|
||||
moved.push((from.clone(), fresh.clone()));
|
||||
fresh
|
||||
}
|
||||
};
|
||||
if let Some(operand) = op.operands.get_mut(i) {
|
||||
*operand = Object::Name(to);
|
||||
}
|
||||
}
|
||||
if !patterns.is_empty() {
|
||||
merged.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
}
|
||||
|
||||
// A form in a layer (/OC) stays in it: its content is marked with the same membership.
|
||||
let layer = match s.dict.get(b"OC") {
|
||||
None => None,
|
||||
@@ -231,7 +295,37 @@ pub(crate) fn ungroup_ops(doc: &Document, resources: &Dict, name: &[u8], translu
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok((out, merged))
|
||||
Ok((out, merged, patterns))
|
||||
}
|
||||
|
||||
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
|
||||
/// of the form's: its own matrix, then `placement`. `None` when it isn't a readable pattern, its
|
||||
/// matrix is unreadable, the result isn't finite or a tiling pattern is too large to copy.
|
||||
fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Object> {
|
||||
let resolved = doc.resolve(entry);
|
||||
let (mut dict, stream) = match &*resolved {
|
||||
Object::Stream(s) => (s.dict.clone(), Some(s)),
|
||||
Object::Dict(d) => (d.clone(), None),
|
||||
_ => return None,
|
||||
};
|
||||
let own = match dict.get(b"Matrix") {
|
||||
None => Matrix::IDENTITY,
|
||||
Some(m) => {
|
||||
let m = doc.resolve(m);
|
||||
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
|
||||
Matrix::from_operands(&nums)?
|
||||
}
|
||||
};
|
||||
let moved = own.then(&placement);
|
||||
if !moved.0.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
dict.set(b"Matrix".to_vec(), Object::Array(moved.0.iter().map(|v| Object::Real(*v)).collect()));
|
||||
match stream {
|
||||
Some(s) if s.raw.len() > MAX_FORM_BYTES => None,
|
||||
Some(s) => Some(Object::Stream(pdfcraft_cos::Stream { dict, raw: s.raw.clone() })),
|
||||
None => Some(Object::Dict(dict)),
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
|
||||
|
||||
@@ -1878,8 +1878,8 @@ milestone = "M7"
|
||||
status = "partial"
|
||||
commands = ["edit.edit_text"]
|
||||
tools = ["text_lines", "text_paragraphs", "text_edit", "text_edit_layout", "text_font_files", "text_font_import", "text_fonts"]
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable"]
|
||||
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside or are drawn translucent are refused, so ungrouping never changes the pixels."
|
||||
evidence = ["crates/edit/src/tests.rs::paragraphs_are_found_and_rewrapped", "crates/edit/src/tests.rs::text_lines_are_found_and_replaced_in_place", "crates/ui-egui/tests/it/editing.rs::editing_existing_text_in_place", "crates/automation/tests/it/automation.rs::editing_existing_text_through_tools", "crates/edit/src/tests.rs::a_line_whose_operator_starts_the_next_stream_can_be_edited", "crates/automation/tests/it/automation.rs::a_line_split_across_content_streams_is_listed_and_edited", "crates/ui-egui/tests/it/editing.rs::selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens", "crates/edit/src/tests.rs::marked_paragraph_boundaries_and_cross_stream_transforms_survive_rewrite", "crates/automation/tests/it/automation.rs::explicit_font_and_disjoint_ranges_save_reopen_through_tools", "crates/edit/src/tests.rs::disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically", "crates/engine/tests/it/document_fonts.rs::opentype_cff_document_font_preserves_program_codes_and_rendering_after_reopen", "crates/ui-egui/tests/it/editing.rs::cropped_images_do_not_intercept_text_and_stay_inside_each_page", "crates/automation/tests/it/automation.rs::clipped_image_bounds_and_text_edit_save_reopen_through_tools", "crates/edit/src/tests.rs::mixed_styles_and_matrix_sizes_form_visual_regions_without_merging_columns", "crates/edit/src/tests.rs::rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen", "crates/ui-egui/tests/it/editing.rs::one_editor_contains_mixed_styles_and_visual_rows", "crates/ui-egui/tests/it/editing.rs::inline_editor_preserves_source_font_and_previews_live_bounds", "crates/fonts/src/pdf/tests.rs::mac_subset_cmap_uses_pdf_unicode_without_changing_source_program", "crates/automation/tests/it/automation.rs::mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools", "crates/ui-egui/tests/it/ui.rs::the_edit_tab_turns_on_edit_text_and_images", "crates/ui-egui/tests/it/editing.rs::the_caret_on_a_small_row_is_as_tall_as_that_row", "crates/edit/src/tests.rs::uzbek_and_kazakh_cyrillic_use_the_bundled_latin_face", "crates/automation/tests/it/automation.rs::editing_uzbek_and_kazakh_cyrillic_through_tools", "crates/edit/src/tests.rs::styled_paragraph_batches_cyrillic_without_changing_source_fonts", "crates/edit/src/tests.rs::styled_paragraph_over_240_fallback_glyphs_splits_and_refuses_atomically", "crates/edit/src/tests.rs::styled_fallback_batches_keep_face_choices_and_range_styles", "crates/edit/src/tests.rs::styled_fallback_shards_keep_text_advances_and_alignment", "crates/edit/src/tests.rs::styled_fallback_budget_is_enforced_before_objects_are_written", "crates/edit/src/tests.rs::styled_fallback_same_base_font_and_character_keep_both_weights", "crates/automation/tests/it/automation.rs::styled_cyrillic_fallback_is_batched_through_tools_and_reopen", "crates/ui-egui/tests/it/editing.rs::escape_in_an_open_paragraph_selects_its_box_for_delete", "crates/edit/src/tests.rs::ungrouping_form_artwork_makes_its_text_editable", "crates/automation/tests/it/automation.rs::ungrouping_a_table_drawn_in_a_form_makes_its_text_editable", "crates/ui-egui/tests/it/editing.rs::ungroup_in_the_artwork_menu_makes_a_forms_text_editable", "crates/edit/src/tests.rs::ungroup_carries_the_placement_into_the_patterns_it_selects", "crates/automation/tests/it/automation.rs::ungrouping_keeps_patterns_where_the_forms_placement_put_them"]
|
||||
notes = "The Edit tab turns Edit text & images on at once, as in Acrobat (leaving the tab puts the tool down and applies an edit in progress); Esc in an open paragraph leaves its text and selects its box, so Delete removes it (the unapplied draft is discarded); click a paragraph box and retype it in place (font reused or Helvetica substituted, colour and spacing kept, rewrapped); a page's /Contents pieces are parsed joined, so a line whose operands and operator sit in different pieces is listed and edited (lines and paragraphs still don't join across pieces; Edit image still parses each piece alone); drag a box to move it or its right-edge handle to rewrap it (edit.text-move-resize); per-character formatting changes are next Since #1116: mixed-style visual regions retain source rows, subset encodings, colour and baseline anchors in one textless-background editor. Explicit paragraph/heading scopes constrain grouping; compatible graphics planes permit cross-stream regions. Full matching document fonts complete absent subset glyphs; installed/file selection, TrueType/OpenType-CFF embedding and disjoint UTF-8 range styles have save/reopen coverage. Single-face OpenType shaping/bidi retains logical text through ToUnicode/ActualText. Replacement text the paragraph's font can't show takes the craft-fonts Japanese faces when they draw all of it (Uzbek қ, Kazakh ә are in none of them), else the bundled Inter face, so those letters write in every build (#2593). Legacy non-SFNT preview, mixed-font/style contextual runs, full structure-tree preservation and author intent in untagged PDFs remain limited; Styled-path outline fallback batches by actual face with 240 glyphs per font and preserves source styles; adjacent equal-font/style characters form runs. At most 15360 distinct fallback glyphs per styled paragraph; missing glyphs are refused before fallback objects are written. Glyph coverage, mixed contextual shaping, single-style fallback and stale-font cleanup are unchanged; see docs/editing-existing-text.md. Text drawn inside a Form XObject (HTML-to-PDF output, stamped or imposed pages) is grouped artwork until Ungroup (artwork right-click menu; image_edit action ungroup) draws that placement in place with its resources merged; knockout groups and transparency groups that blend inside or are drawn translucent are refused, and a pattern the form fills with is drawn from a copy whose /Matrix carries the placement (ISO 32000-2 §8.7.2), so ungrouping never changes the pixels."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
|
||||
Reference in new issue
Block a user