mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 17:31:35 +00:00
Text drawn inside a Form XObject was one piece of artwork in Edit text & images: HTML-to-PDF output that wraps a block with a translucent picture in a transparency group showed a whole table as a single box with no editable text (#3005). Ungroup (artwork right-click menu; image_edit action ungroup) replaces that placement's Do with the form's content, wrapped as q <Matrix> cm <BBox> re W n ... Q, its resources merged into a copy of the page's under fresh names where they clash (fonts, XObjects, graphics states, colour spaces incl. inline images', patterns, shadings, properties). The form object and its other placements are untouched; a form in a layer keeps it as /OC ... BDC ... EMC. Refused rather than changed: knockout groups, transparency groups that blend inside (used gs, nested forms) or are drawn translucent, blended or soft-masked at the Do (followed per parameter across q/Q and streams), tagged form content, content that can't be decoded strictly or parsed, and unbalanced Q. cos gains Stream::decoded_strict_within for that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RUNQSPVXx3dDBabRZHAnjz
This commit is contained in:
1 parent
fa5f9f8c3b
commit
6eea737e14
36 files changed
+1146
-6
No files matched your search
@@ -413,6 +413,7 @@ impl Automation {
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
"delete" => ImageEdit::Delete,
|
||||
"ungroup" => ImageEdit::Ungroup,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
|
||||
@@ -1378,14 +1378,14 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place), delete, or ungroup (Form artwork only: its content is drawn in place, so its text becomes editable with text_paragraphs/text_edit and its pictures separate; refused for knockout groups and for transparency groups that blend inside or are drawn translucent, which would composite differently). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete", "ungroup"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
|
||||
@@ -10190,3 +10190,86 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how
|
||||
/// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece
|
||||
/// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its
|
||||
/// text becomes paragraphs that `text_edit` changes, and undo brings the form back.
|
||||
fn table_in_a_form_pdf() -> Vec<u8> {
|
||||
let table = b"BT /F1 12 Tf 1 0 0 1 20 120 Tm (Revenue) Tj 1 0 0 1 120 120 Tm (153,300.00) Tj 1 0 0 1 20 100 Tm (Costs) Tj 1 0 0 1 120 100 Tm (382,110.00) Tj ET 0.6 G 0.5 w 15 115 m 220 115 l S";
|
||||
// Two pictures with soft masks overlapping each other, the second also over the table's
|
||||
// last cell: a translucent wash, and a picture whose corner pixels are see-through.
|
||||
let inner = [&table[..], b" q 40 0 0 20 30 40 cm /Sig Do Q q 60 0 0 40 50 30 cm /Wash Do Q q 100 0 0 30 110 90 cm /Sig Do Q"].concat();
|
||||
let rgb: Vec<u8> = std::iter::repeat_n([20u8, 60, 220], 16).flatten().collect();
|
||||
let alpha: Vec<u8> = (0..16).map(|i| if i % 3 == 0 { 255 } else { 0 }).collect();
|
||||
let page = b"q /Fm1 Do Q";
|
||||
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 240 160] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
|
||||
stream("<<", page),
|
||||
stream(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 240 160] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /Font << /F1 6 0 R >> /XObject << /Sig 7 0 R /Wash 9 0 R >> >>",
|
||||
&inner,
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 8 0 R", &rgb),
|
||||
stream("<< /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceGray /BitsPerComponent 8", &alpha),
|
||||
stream(
|
||||
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 10 0 R",
|
||||
&[230, 40, 40].repeat(4),
|
||||
),
|
||||
stream("<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8", &[128, 96, 160, 64]),
|
||||
];
|
||||
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", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouping_a_table_drawn_in_a_form_makes_its_text_editable() {
|
||||
let dir = workdir("ungroup");
|
||||
std::fs::write(dir.join("table.pdf"), table_in_a_form_pdf()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
|
||||
let paragraphs = |a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 1 }))["paragraphs"].as_array().unwrap().len();
|
||||
let objects = |a: &mut Automation| ok(a, "object_list", json!({ "doc": doc, "page": 1 }))["objects"].as_array().unwrap().len();
|
||||
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "one piece of artwork, no text");
|
||||
let (w, h, before) = a.render_pixels(doc, 1, 2.0).unwrap();
|
||||
|
||||
ok(&mut a, "image_edit", json!({ "doc": doc, "page": 1, "image": 1, "action": "ungroup" }));
|
||||
let (w2, h2, after) = a.render_pixels(doc, 1, 2.0).unwrap();
|
||||
assert_eq!((w, h), (w2, h2));
|
||||
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, "ungrouping draws the same pixels");
|
||||
assert!(paragraphs(&mut a) >= 4, "the table's text is page text now");
|
||||
let images = ok(&mut a, "page_images", json!({ "doc": doc, "page": 1 }));
|
||||
let kinds: Vec<_> = images["images"].as_array().unwrap().iter().map(|i| i["kind"].clone()).collect();
|
||||
assert_eq!(kinds, vec![json!("image"); 3], "the pictures are separate pictures now: {images}");
|
||||
|
||||
let list = ok(&mut a, "text_paragraphs", json!({ "doc": doc, "page": 1 }));
|
||||
let target =
|
||||
list["paragraphs"].as_array().unwrap().iter().find(|p| p["text"].as_str().is_some_and(|t| t.contains("382,110.00"))).unwrap()["paragraph"]
|
||||
.clone();
|
||||
ok(&mut a, "text_edit", json!({ "doc": doc, "page": 1, "paragraph": target, "text": "400,000.00" }));
|
||||
let text = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }));
|
||||
let s = text.to_string();
|
||||
assert!(s.contains("400,000.00") && !s.contains("382,110.00"), "{s}");
|
||||
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!((paragraphs(&mut a), objects(&mut a)), (0, 1), "undo brings the form back");
|
||||
}
|
||||
@@ -323,8 +323,14 @@ impl Stream {
|
||||
|
||||
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
|
||||
pub fn decoded_strict(&self) -> Result<Vec<u8>, CosError> {
|
||||
self.decoded_strict_within(MAX_DECODED)
|
||||
}
|
||||
|
||||
/// Like [`Stream::decoded_strict`], bounded by `max` (and [`MAX_DECODED`]): for data that
|
||||
/// will be copied elsewhere, where a damaged stream must not pass for a shorter one.
|
||||
pub fn decoded_strict_within(&self, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
|
||||
self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
|
||||
self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`Stream::decoded_strict`] bounded by `max` and counted like
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Edit a PDF ▸ edit existing images and grouped artwork (Image/Form XObjects painted by `Do`
|
||||
//! in its own content streams), and changing one: move/resize/rotate/flip (the `Do` is wrapped in
|
||||
//! `q … cm … Q` with the extra transform), replace (it draws another XObject in the same place),
|
||||
//! or delete. Only the stream that draws it is rewritten, as a new object.
|
||||
//! delete, or ungroup grouped artwork (its content drawn in place, see `ungroup`). Only the stream that draws it is rewritten, as a new object.
|
||||
|
||||
use kurbo::{BezPath, Point, Shape};
|
||||
use pdfcraft_content::{Matrix, Op, Pieces, parse};
|
||||
@@ -394,6 +394,9 @@ pub enum ImageChange {
|
||||
/// Draw this image XObject instead, in the same place.
|
||||
Replace(ObjRef),
|
||||
Delete,
|
||||
/// Grouped Form artwork only: draw the form's content in place of its `Do`, so its text and
|
||||
/// pictures become the page's own and can be edited one by one (#2964).
|
||||
Ungroup,
|
||||
}
|
||||
|
||||
/// The user-space transform that maps box `from` onto box `to` (move and resize).
|
||||
@@ -493,6 +496,17 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
ImageChange::Crop(keep) => {
|
||||
return crop_image(doc, &p, &all, &img, *keep);
|
||||
}
|
||||
ImageChange::Ungroup => {
|
||||
if !img.is_form {
|
||||
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
|
||||
}
|
||||
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let parsed: Vec<Vec<Op>> = all.iter().map(|(_, d)| parse(d).ops).collect();
|
||||
let translucent = crate::ungroup::translucent_at(doc, &res, &parsed, img.stream, img.op);
|
||||
let (inline, merged) = crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent)?;
|
||||
resources = Some(merged);
|
||||
inline
|
||||
}
|
||||
ImageChange::Replace(r) => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Invalid(
|
||||
|
||||
@@ -43,6 +43,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
|
||||
mod flatten;
|
||||
pub use flatten::{flatten, flatten_fill_sign};
|
||||
pub mod images;
|
||||
mod ungroup;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
|
||||
@@ -3778,6 +3778,165 @@ fn form_artwork_edits_only_the_selected_placement() {
|
||||
assert!(images::page_images(&doc, 0).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A page of [`fixture`] drawing Form artwork `form` (with `dict`'s entries and `content`) twice,
|
||||
/// then a caption, after `prefix`. Returns the document and the form's object.
|
||||
fn page_with_form(dict: &[(&str, Object)], content: &[u8], prefix: &str, page_states: Option<Dict>) -> (Document, pdfcraft_cos::ObjRef) {
|
||||
let mut doc = fixture();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"Matrix".to_vec(), Object::Array([2, 0, 0, 2, 0, 0].map(Object::Int).to_vec()));
|
||||
for (k, v) in dict {
|
||||
form.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, content)));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
if let Some(states) = page_states {
|
||||
d.set(b"ExtGState".to_vec(), Object::Dict(states));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let page = format!("{prefix} q 1 0 0 1 40 80 cm /Art Do Q q 1 0 0 1 300 400 cm /Art Do Q BT /F1 9 Tf 1 0 0 1 40 40 Tm (Caption) Tj ET");
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), page.as_bytes())));
|
||||
(reopen(&doc), artwork)
|
||||
}
|
||||
|
||||
/// #2964 / #3005: Ungroup draws a form's content in place of one of its `Do`s, so its text is the
|
||||
/// page's own and edits like any other. Its fonts are merged under fresh names where the page's
|
||||
/// clash, the form object and the other placement stay as they were, and a page sharing the
|
||||
/// content stream is untouched.
|
||||
#[test]
|
||||
fn ungrouping_form_artwork_makes_its_text_editable() {
|
||||
let mut times = Dict::new();
|
||||
times.set(b"Type".to_vec(), Object::name("Font"));
|
||||
times.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
times.set(b"BaseFont".to_vec(), Object::name("Times-Roman"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Dict(times.clone()));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
// An unclosed `q` too: Ungroup ends the form with the state it was drawn with.
|
||||
let content = b"BT /F1 12 Tf 1 0 0 1 20 30 Tm (Inside) Tj ET q 0 0 1 rg 5 5 10 10 re f";
|
||||
let (mut doc, artwork) = page_with_form(&[("Resources", Object::Dict(res))], content, "", None);
|
||||
let original = doc.get(artwork);
|
||||
let texts = |doc: &Document, page: usize| text::text_lines(doc, page).unwrap().into_iter().map(|l| l.text).collect::<Vec<_>>();
|
||||
assert_eq!(texts(&doc, 0), ["Caption"], "form text isn't the page's own");
|
||||
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 2);
|
||||
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
let inside = lines.iter().find(|l| l.text == "Inside").expect("the form's text is now page text");
|
||||
// Tm (20, 30) through the form's Matrix (×2) and the placement (40, 80): baseline (80, 140).
|
||||
assert!((inside.rect[0] - 80.0).abs() < 1.0 && inside.rect[1] < 140.0 && 140.0 < inside.rect[3], "{:?}", inside.rect);
|
||||
assert!((inside.size - 24.0).abs() < 1e-6, "12 pt scaled ×2: {}", inside.size);
|
||||
assert!(inside.base_font.contains("Times"), "the form's own F1, not the page's Courier: {}", inside.base_font);
|
||||
assert_eq!(images::page_images(&doc, 0).unwrap().len(), 1, "the other placement is still the form");
|
||||
assert_eq!(*doc.get(artwork), *original, "the form object is untouched");
|
||||
assert_eq!(texts(&doc, 1), ["Caption"], "page 2 shared the content stream and keeps drawing the form");
|
||||
assert_eq!(images::page_images(&doc, 1).unwrap().len(), 2);
|
||||
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 fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(fonts.get(b"F1"), Some(&Object::Ref(pdfcraft_cos::ObjRef::new(8, 0))), "the page's F1 is kept");
|
||||
assert_eq!(fonts.get(b"PCUg1"), Some(&Object::Dict(times)), "the form's F1 joins under a fresh name");
|
||||
let refs: Vec<Object> = match doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref() {
|
||||
Object::Array(a) => a.clone(),
|
||||
_ => vec![page.dict.get(b"Contents").unwrap().clone()],
|
||||
};
|
||||
let data: Vec<u8> = refs
|
||||
.iter()
|
||||
.flat_map(|r| match doc.resolve(r).as_ref() {
|
||||
Object::Stream(s) => [
|
||||
s.decoded().unwrap(),
|
||||
b"
|
||||
"
|
||||
.to_vec(),
|
||||
]
|
||||
.concat(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let ops = pdfcraft_content::parse(&data).ops;
|
||||
let depth = ops.iter().fold(0i32, |d, op| d + i32::from(op.is("q")) - i32::from(op.is("Q")));
|
||||
assert_eq!(depth, 0, "the unclosed q inside the form is closed");
|
||||
}
|
||||
|
||||
/// Ungroup refuses what would draw differently inline: knockout groups, transparency groups that
|
||||
/// blend inside or are drawn translucent, and forms whose content restores more states than it
|
||||
/// saves. Plain groups drawn opaque, and translucency that a later `gs` turns off, are fine.
|
||||
#[test]
|
||||
fn ungroup_refuses_what_would_draw_differently() {
|
||||
let group = |extra: &[(&str, Object)]| {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"S".to_vec(), Object::name("Transparency"));
|
||||
for (k, v) in extra {
|
||||
g.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
Object::Dict(g)
|
||||
};
|
||||
let gs = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
for (k, v) in entries {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
Object::Dict(d)
|
||||
};
|
||||
let states = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
for (k, v) in entries {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
d
|
||||
};
|
||||
let ungroup = |dict: &[(&str, Object)], content: &[u8], prefix: &str, page_states: Option<Dict>| {
|
||||
let (mut doc, _) = page_with_form(dict, content, prefix, page_states);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup)
|
||||
};
|
||||
let fill = b"1 0 0 rg 0 0 10 10 re f";
|
||||
let refused = |r: Result<(), EditError>, why: &str| match r {
|
||||
Err(EditError::Invalid(m)) => assert!(m.contains(why), "{m}"),
|
||||
other => panic!("expected a refusal mentioning {why:?}, got {other:?}"),
|
||||
};
|
||||
refused(ungroup(&[("Group", group(&[("K", Object::Bool(true))]))], fill, "", None), "knockout");
|
||||
let mut inner = Dict::new();
|
||||
inner.set(b"ExtGState".to_vec(), Object::Dict(states(&[("M", gs(&[("BM", Object::name("Multiply"))]))])));
|
||||
refused(ungroup(&[("Group", group(&[])), ("Resources", Object::Dict(inner))], b"/M gs 1 0 0 rg 0 0 10 10 re f", "", None), "blend");
|
||||
let half =
|
||||
|| Some(states(&[("H", gs(&[("ca", Object::Real(0.5))])), ("O", gs(&[("LW", Object::Int(2))])), ("One", gs(&[("ca", Object::Int(1))]))]));
|
||||
refused(ungroup(&[("Group", group(&[]))], fill, "/H gs", half()), "translucent");
|
||||
// A later gs that sets only the line width leaves the fill alpha at 0.5.
|
||||
refused(ungroup(&[("Group", group(&[]))], fill, "/H gs /O gs", half()), "translucent");
|
||||
// A later gs that sets the fill alpha back to 1 ends it.
|
||||
ungroup(&[("Group", group(&[]))], fill, "/H gs /One gs", half()).unwrap();
|
||||
// The translucency ends with its q … Q scope.
|
||||
ungroup(&[("Group", group(&[]))], fill, "q /H gs Q", half()).unwrap();
|
||||
// Without a group, translucency at the Do applies to each part just as it would inline.
|
||||
ungroup(&[], fill, "/H gs", half()).unwrap();
|
||||
ungroup(&[("Group", group(&[]))], fill, "", None).unwrap();
|
||||
refused(ungroup(&[], b"Q 1 0 0 rg 0 0 10 10 re f", "", None), "restores more");
|
||||
// A raster image isn't grouped artwork.
|
||||
let mut doc = fixture();
|
||||
let mut img = Dict::new();
|
||||
for (k, v) in [("Subtype", Object::name("Image")), ("Width", Object::Int(1)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))] {
|
||||
img.set(k.as_bytes().to_vec(), v);
|
||||
}
|
||||
img.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
let pic = doc.add(Object::Stream(Stream::flate(img, b"\x00")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Pic".to_vec(), Object::Ref(pic));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q 10 0 0 10 0 0 cm /Pic Do Q")));
|
||||
let mut doc = reopen(&doc);
|
||||
refused(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup), "only grouped Form artwork");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_take_new_formatting() {
|
||||
let mut doc = text_page("BT /F1 10 Tf 12 TL 100 700 Td (One two three four five six seven) Tj T* (eight nine ten eleven twelve) Tj ET");
|
||||
@@ -8576,3 +8735,457 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning()
|
||||
assert_eq!(page_content_bytes(&doc, 0), before);
|
||||
assert_eq!(page_content_bytes(&reopen(&doc), 0), before);
|
||||
}
|
||||
|
||||
/// Ungroup renames every kind of resource a content stream names, where the page already uses
|
||||
/// that name for something else: fonts, XObjects, graphics states, colour spaces (also an inline
|
||||
/// image's /CS), patterns, shadings and marked-content properties. Each name the inline content
|
||||
/// uses resolves, in the page's merged resources, to the form's own object.
|
||||
#[test]
|
||||
fn ungroup_renames_every_kind_of_resource() {
|
||||
let tag = |s: &str| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Tag".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: s.as_bytes().to_vec(), hex: false }));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let categories = ["Font", "XObject", "ExtGState", "ColorSpace", "Pattern", "Shading", "Properties"];
|
||||
let names = ["F1", "Art", "G", "C", "P", "S", "M"];
|
||||
// The page uses every one of these names for something else; the form's are "form …".
|
||||
let mut form_res = Dict::new();
|
||||
let mut page_res = Dict::new();
|
||||
for (cat, name) in categories.iter().zip(names) {
|
||||
let mut f = Dict::new();
|
||||
f.set(name.as_bytes().to_vec(), tag(&format!("form {cat}")));
|
||||
form_res.set(cat.as_bytes().to_vec(), Object::Dict(f));
|
||||
if *cat != "Font" && *cat != "XObject" {
|
||||
let mut p = Dict::new();
|
||||
p.set(name.as_bytes().to_vec(), tag(&format!("page {cat}")));
|
||||
page_res.set(cat.as_bytes().to_vec(), Object::Dict(p));
|
||||
}
|
||||
}
|
||||
let content = b"/G gs /C cs /Pattern cs /P scn /S sh /OC /M BDC EMC /Art Do BT /F1 9 Tf (x) Tj ET BI /CS /C /W 1 /H 1 /BPC 8 ID \x00 EI";
|
||||
let (mut doc, _) = page_with_form(&[("Resources", Object::Dict(form_res))], content, "", None);
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
for (k, v) in page_res.iter() {
|
||||
d.set(k.clone(), v.clone());
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
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 contents = doc.resolve(page.dict.get(b"Contents").unwrap());
|
||||
let Object::Stream(s) = contents.as_ref() else { panic!("one content stream") };
|
||||
let ops = pdfcraft_content::parse(&s.decoded().unwrap()).ops;
|
||||
let lookup = |cat: &str, name: &[u8]| {
|
||||
let d = doc.resolve(res.get(cat.as_bytes()).unwrap()).as_dict().cloned().unwrap();
|
||||
d.get(name).map(|o| doc.resolve(o).as_ref().clone())
|
||||
};
|
||||
let used: Vec<(&str, Vec<u8>)> = ops
|
||||
.iter()
|
||||
.filter_map(|op| match op.op.as_slice() {
|
||||
b"gs" => Some(("ExtGState", op.name(0)?.to_vec())),
|
||||
b"cs" if op.name(0) != Some(b"Pattern") => Some(("ColorSpace", op.name(0)?.to_vec())),
|
||||
b"scn" => Some(("Pattern", op.name(0)?.to_vec())),
|
||||
b"sh" => Some(("Shading", op.name(0)?.to_vec())),
|
||||
b"BDC" => Some(("Properties", op.name(1)?.to_vec())),
|
||||
b"Do" if op.name(0) != Some(b"Art") || ops.iter().filter(|o| o.is("Do")).count() == 1 => Some(("XObject", op.name(0)?.to_vec())),
|
||||
b"Tf" => Some(("Font", op.name(0)?.to_vec())),
|
||||
b"BI" => Some(("ColorSpace", op.inline.as_ref()?.0.get(b"CS")?.as_name()?.to_vec())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
used.iter().filter(|(c, _)| *c == "ColorSpace").all(|(_, n)| lookup("ColorSpace", n) == Some(tag("form ColorSpace"))),
|
||||
"cs and the inline image's /CS both point at the form's colour space"
|
||||
);
|
||||
assert_eq!(lookup("ExtGState", b"G"), Some(tag("page ExtGState")), "the page's own G is kept");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[test]
|
||||
fn ungrouped_content_keeps_the_forms_matrix_and_clip() {
|
||||
let content = b"0 0 1 rg -50 -50 300 300 re f";
|
||||
let page_ops = |doc: &Document| {
|
||||
let page = pdfcraft_model::pages(doc).into_iter().next().unwrap();
|
||||
let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") };
|
||||
pdfcraft_content::parse(&s.decoded().unwrap()).ops
|
||||
};
|
||||
let (mut doc, _) = page_with_form(&[], content, "", None);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
let ops = page_ops(&reopen(&doc));
|
||||
let words: Vec<String> = ops.iter().map(|o| String::from_utf8_lossy(&o.op).into_owned()).collect();
|
||||
// q 1 0 0 1 40 80 cm | q 2 0 0 2 0 0 cm 0 0 100 50 re W n <form> Q | Q …
|
||||
let at = words.iter().position(|w| w == "re").expect("a clip");
|
||||
assert_eq!(words[at - 3..at + 3], ["cm", "q", "cm", "re", "W", "n"], "{words:?}");
|
||||
assert_eq!(ops[at - 1].nums::<6>(), Some([2.0, 0.0, 0.0, 2.0, 0.0, 0.0]), "the form's Matrix");
|
||||
assert_eq!(ops[at].nums::<4>(), Some([0.0, 0.0, 100.0, 50.0]), "the form's BBox");
|
||||
assert_eq!(words.iter().filter(|w| *w == "Do").count(), 1, "the other placement still draws the form");
|
||||
// No Matrix, no cm.
|
||||
let (mut doc, artwork) = page_with_form(&[], content, "", None);
|
||||
if let Object::Stream(mut s) = doc.get(artwork).as_ref().clone() {
|
||||
s.dict.remove(b"Matrix");
|
||||
doc.set(artwork, Object::Stream(s));
|
||||
}
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
let ops = page_ops(&reopen(&doc));
|
||||
let at = ops.iter().position(|o| o.is("re")).unwrap();
|
||||
assert!(ops[at - 1].is("q"), "no Matrix, no cm: {:?}", ops[at - 1]);
|
||||
}
|
||||
|
||||
/// Merging resources: an object the page already has under the same name keeps that name, a
|
||||
/// fresh name never takes one the page already uses, and a form without resources of its own
|
||||
/// (PDF 1.1) draws with the page's as they are.
|
||||
#[test]
|
||||
fn ungroup_merges_resources_without_duplicates_or_clashes() {
|
||||
let courier = Object::Ref(pdfcraft_cos::ObjRef::new(8, 0));
|
||||
let mut times = Dict::new();
|
||||
times.set(b"BaseFont".to_vec(), Object::name("Times-Roman"));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), courier.clone());
|
||||
fonts.set(b"F2".to_vec(), Object::Dict(times.clone()));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let content = b"BT /F1 9 Tf (a) Tj /F2 9 Tf (b) Tj ET";
|
||||
let (mut doc, _) = page_with_form(&[("Resources", Object::Dict(res))], content, "", None);
|
||||
// The page already uses PCUg1 for another font.
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut f = Dict::new();
|
||||
f.set(b"F1".to_vec(), courier.clone());
|
||||
f.set(b"F2".to_vec(), Object::name("PageF2"));
|
||||
f.set(b"PCUg1".to_vec(), Object::name("Taken"));
|
||||
d.set(b"Font".to_vec(), Object::Dict(f));
|
||||
})
|
||||
.unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
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 fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap();
|
||||
let names: Vec<String> = fonts.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect();
|
||||
assert_eq!(names.len(), 4, "F1 shared, F2 renamed once: {names:?}");
|
||||
assert_eq!(fonts.get(b"PCUg1"), Some(&Object::name("Taken")), "a fresh name never replaces one in use");
|
||||
assert_eq!(fonts.get(b"PCUg2"), Some(&Object::Dict(times)), "the form's F2 under the next free name");
|
||||
let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") };
|
||||
let used: Vec<Vec<u8>> =
|
||||
pdfcraft_content::parse(&s.decoded().unwrap()).ops.iter().filter(|o| o.is("Tf")).filter_map(|o| o.name(0).map(<[u8]>::to_vec)).collect();
|
||||
assert!(used.contains(&b"F1".to_vec()) && used.contains(&b"PCUg2".to_vec()), "{used:?}");
|
||||
|
||||
// No resources of its own: nothing to merge or rename.
|
||||
let (mut doc, _) = page_with_form(&[], b"BT /F1 9 Tf (c) Tj ET", "", None);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(text::text_lines(&doc, 0).unwrap().iter().any(|l| l.text == "c" && l.base_font.contains("Courier")));
|
||||
}
|
||||
|
||||
/// Ungroup never drops what it can't read: a form whose content can't be decoded or parsed is
|
||||
/// refused and the page is unchanged. (A form without a usable BBox or Matrix isn't offered as
|
||||
/// artwork at all.)
|
||||
#[test]
|
||||
fn ungroup_refuses_unreadable_forms() {
|
||||
let refused = |dict: &[(&str, Object)], content: &[u8], raw: Option<&[u8]>| {
|
||||
let (mut doc, artwork) = page_with_form(dict, content, "", None);
|
||||
if let Some(raw) = raw
|
||||
&& let Object::Stream(mut s) = doc.get(artwork).as_ref().clone()
|
||||
{
|
||||
s.raw = raw.to_vec().into();
|
||||
doc.set(artwork, Object::Stream(s));
|
||||
}
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup);
|
||||
assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("can't be ungrouped")), "{r:?}");
|
||||
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "nothing changed");
|
||||
};
|
||||
// Flate data that isn't Flate.
|
||||
refused(&[], b"0 0 1 1 re f", Some(b"not deflated at all"));
|
||||
// Bytes that aren't operators.
|
||||
refused(&[], b"0 0 1 1 re f ) ) ]]", None);
|
||||
}
|
||||
|
||||
/// Translucency at the `Do` is followed parameter by parameter: stroke alpha counts, a soft mask
|
||||
/// of /None doesn't, a blend mode set on the page counts (Compatible doesn't, and an array's first
|
||||
/// entry decides), it carries over from an earlier content stream, and a group whose /S isn't
|
||||
/// Transparency isn't checked.
|
||||
#[test]
|
||||
fn ungroup_follows_the_graphics_state_at_the_do() {
|
||||
let gs = |entries: &[(&str, Object)]| {
|
||||
let mut d = Dict::new();
|
||||
for (k, v) in entries {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
Object::Dict(d)
|
||||
};
|
||||
let mut states = Dict::new();
|
||||
for (k, v) in [
|
||||
("Stroke", gs(&[("CA", Object::Real(0.4))])),
|
||||
("NoMask", gs(&[("SMask", Object::name("None"))])),
|
||||
("Mask", gs(&[("SMask", gs(&[("S", Object::name("Luminosity"))]))])),
|
||||
("Mul", gs(&[("BM", Object::name("Multiply"))])),
|
||||
("Compat", gs(&[("BM", Object::name("Compatible"))])),
|
||||
("MulArr", gs(&[("BM", Object::Array(vec![Object::name("Multiply"), Object::name("Normal")]))])),
|
||||
("NormArr", gs(&[("BM", Object::Array(vec![Object::name("Normal"), Object::name("Multiply")]))])),
|
||||
] {
|
||||
states.set(k.as_bytes().to_vec(), v);
|
||||
}
|
||||
let group = |s: &str| {
|
||||
let mut g = Dict::new();
|
||||
g.set(b"S".to_vec(), Object::name(s));
|
||||
Object::Dict(g)
|
||||
};
|
||||
let try_with = |prefix: &str, s: &str| {
|
||||
let (mut doc, _) = page_with_form(&[("Group", group(s))], b"0 0 1 1 re f", prefix, Some(states.clone()));
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok()
|
||||
};
|
||||
assert!(!try_with("/Stroke gs", "Transparency"), "stroke alpha");
|
||||
assert!(try_with("/NoMask gs", "Transparency"), "SMask /None");
|
||||
assert!(!try_with("/Mask gs", "Transparency"), "a soft mask");
|
||||
assert!(!try_with("/Mul gs", "Transparency"), "Multiply");
|
||||
assert!(try_with("/Compat gs", "Transparency"), "Compatible");
|
||||
assert!(!try_with("/MulArr gs", "Transparency"), "an array's first entry decides: Multiply");
|
||||
assert!(try_with("/NormArr gs", "Transparency"), "an array's first entry decides: Normal");
|
||||
assert!(try_with("/Mul gs", "Other"), "not a transparency group");
|
||||
// Nor are the blend modes inside a group that isn't a transparency group.
|
||||
let mut inner = Dict::new();
|
||||
let mut inner_states = Dict::new();
|
||||
inner_states.set(b"Mul".to_vec(), gs(&[("BM", Object::name("Multiply"))]));
|
||||
inner.set(b"ExtGState".to_vec(), Object::Dict(inner_states));
|
||||
let (mut doc, _) = page_with_form(&[("Group", group("Other")), ("Resources", Object::Dict(inner.clone()))], b"/Mul gs 0 0 1 1 re f", "", None);
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok(), "blending inside a non-transparency group");
|
||||
let (mut doc, _) = page_with_form(&[("Group", group("Transparency")), ("Resources", Object::Dict(inner))], b"/Mul gs 0 0 1 1 re f", "", None);
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_err(), "blending inside a transparency group");
|
||||
// The gs in an earlier content stream of the page.
|
||||
let (mut doc, _) = page_with_form(&[("Group", group("Transparency"))], b"0 0 1 1 re f", "", Some(states.clone()));
|
||||
let first = doc.add(Object::Stream(Stream::flate(Dict::new(), b"/Mul gs")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(3, 0), |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(pdfcraft_cos::ObjRef::new(7, 0))]));
|
||||
})
|
||||
.unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
assert!(images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_err(), "carried from the first stream");
|
||||
}
|
||||
|
||||
/// A transparency group's blend modes are looked for where they are used: in the `gs` its
|
||||
/// content sets (not every ExtGState its resources happen to list) and inside the forms it
|
||||
/// draws, at any depth. A nested form that draws itself counts as blending, without looping.
|
||||
#[test]
|
||||
fn ungroup_looks_for_blend_modes_where_they_are_used() {
|
||||
let gs = |bm: &str| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"BM".to_vec(), Object::name(bm));
|
||||
Object::Dict(d)
|
||||
};
|
||||
let mut group = Dict::new();
|
||||
group.set(b"S".to_vec(), Object::name("Transparency"));
|
||||
let attempt = |form_content: &[u8], nested_content: Option<(&[u8], &str)>, self_loop: bool| {
|
||||
let mut doc = fixture();
|
||||
let mut inner_res = Dict::new();
|
||||
let mut states = Dict::new();
|
||||
states.set(b"Mul".to_vec(), gs("Multiply"));
|
||||
states.set(b"Norm".to_vec(), gs("Normal"));
|
||||
inner_res.set(b"ExtGState".to_vec(), Object::Dict(states));
|
||||
let nested = nested_content.map(|(content, _)| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(inner_res.clone()));
|
||||
doc.add(Object::Stream(Stream::flate(d, content)))
|
||||
});
|
||||
if let (Some(n), true) = (nested, self_loop) {
|
||||
// The nested form draws itself.
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
let mut res = inner_res.clone();
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Me".to_vec(), Object::Ref(n));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
d.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
doc.set(n, Object::Stream(Stream::flate(d, b"/Me Do")));
|
||||
}
|
||||
let mut res = inner_res.clone();
|
||||
if let (Some(n), Some((_, name))) = (nested, nested_content) {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(name.as_bytes().to_vec(), Object::Ref(n));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
}
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"Group".to_vec(), Object::Dict(group.clone()));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, form_content)));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q")));
|
||||
let mut doc = reopen(&doc);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup).is_ok()
|
||||
};
|
||||
assert!(attempt(b"/Norm gs 0 0 1 1 re f", None, false), "Normal is fine");
|
||||
assert!(attempt(b"0 0 1 1 re f", None, false), "Multiply listed but never used");
|
||||
assert!(!attempt(b"/Mul gs 0 0 1 1 re f", None, false), "Multiply used");
|
||||
assert!(!attempt(b"/Inner Do", Some((b"/Mul gs 0 0 1 1 re f", "Inner")), false), "Multiply inside a nested form");
|
||||
assert!(attempt(b"/Inner Do", Some((b"/Norm gs 0 0 1 1 re f", "Inner")), false), "a nested form without blending");
|
||||
assert!(!attempt(b"/Inner Do", Some((b"", "Inner")), true), "a nested form that draws itself");
|
||||
}
|
||||
|
||||
/// A transparency group that draws a nested form whose content can't be read is refused: what
|
||||
/// it blends can't be known, so it isn't guessed.
|
||||
#[test]
|
||||
fn ungroup_refuses_a_group_drawing_an_unreadable_form() {
|
||||
let mut doc = fixture();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let broken = doc.add(Object::Stream(Stream::from_raw(d, b"not deflated at all".to_vec())));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Inner".to_vec(), Object::Ref(broken));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
let mut group = Dict::new();
|
||||
group.set(b"S".to_vec(), Object::name("Transparency"));
|
||||
let (mut with_group, _) = {
|
||||
let mut doc2 = doc.clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"Group".to_vec(), Object::Dict(group));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
let artwork = doc2.add(Object::Stream(Stream::flate(form, b"/Inner Do")));
|
||||
doc2.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
doc2.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q")));
|
||||
(reopen(&doc2), artwork)
|
||||
};
|
||||
let r = images::change_image(&mut with_group, 0, 0, &images::ImageChange::Ungroup);
|
||||
assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{r:?}");
|
||||
}
|
||||
|
||||
/// A form in a layer keeps its layer once ungrouped: its content is marked `/OC <name> BDC … EMC`
|
||||
/// with a Properties entry for the same optional-content object (an existing one reused), so
|
||||
/// hiding the layer still hides it. Tagged form content is refused: inline, it would lose its
|
||||
/// structure element.
|
||||
#[test]
|
||||
fn ungroup_keeps_a_forms_layer_and_refuses_tagged_content() {
|
||||
let mut doc = fixture();
|
||||
let mut ocg = Dict::new();
|
||||
ocg.set(b"Type".to_vec(), Object::name("OCG"));
|
||||
ocg.set(b"Name".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"Walls".to_vec(), hex: false }));
|
||||
let layer = doc.add(Object::Dict(ocg));
|
||||
let base = reopen(&doc);
|
||||
let ops_after = |reuse: bool| {
|
||||
let mut doc = base.clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"OC".to_vec(), Object::Ref(layer));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, b"0 0 1 rg 0 0 10 10 re f")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
if reuse {
|
||||
let mut props = Dict::new();
|
||||
props.set(b"MC0".to_vec(), Object::Ref(layer));
|
||||
d.set(b"Properties".to_vec(), Object::Dict(props));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q")));
|
||||
let mut doc = reopen(&doc);
|
||||
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 props = doc.resolve(res.get(b"Properties").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = doc.resolve(page.dict.get(b"Contents").unwrap()).as_ref().clone() else { panic!("one stream") };
|
||||
(pdfcraft_content::parse(&s.decoded().unwrap()).ops, props)
|
||||
};
|
||||
for reuse in [false, true] {
|
||||
let (ops, props) = ops_after(reuse);
|
||||
let bdc = ops.iter().position(|o| o.is("BDC")).expect("the content is marked");
|
||||
assert_eq!(ops[bdc].name(0), Some(&b"OC"[..]));
|
||||
let name = ops[bdc].name(1).unwrap().to_vec();
|
||||
assert_eq!(props.get(&name), Some(&Object::Ref(layer)), "the marked content names the form's layer");
|
||||
if reuse {
|
||||
assert_eq!(name, b"MC0", "the page's own entry for that layer is reused");
|
||||
assert_eq!(props.iter().count(), 1);
|
||||
}
|
||||
let emc = ops.iter().position(|o| o.is("EMC")).expect("closed");
|
||||
let (q, last_q) = (ops.iter().position(|o| o.is("q")).unwrap(), ops.iter().rposition(|o| o.is("Q")).unwrap());
|
||||
assert!(
|
||||
q < bdc && bdc < emc && emc < last_q,
|
||||
"inside the placement's q … Q: {:?}",
|
||||
ops.iter().map(|o| String::from_utf8_lossy(&o.op).into_owned()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
let mut doc = base.clone();
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"StructParent".to_vec(), Object::Int(3));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, b"0 0 1 1 re f")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q")));
|
||||
let mut doc = reopen(&doc);
|
||||
let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup);
|
||||
assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("tagged")), "{r:?}");
|
||||
}
|
||||
|
||||
/// A transparency group drawing a nested form whose content has parts that can't be read as
|
||||
/// operators is refused too: a blend mode could hide in what was skipped.
|
||||
#[test]
|
||||
fn ungroup_refuses_a_group_drawing_a_partly_unreadable_form() {
|
||||
let mut doc = fixture();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec()));
|
||||
let nested = doc.add(Object::Stream(Stream::flate(d, b"0 0 1 1 re f ) ) ]]")));
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Inner".to_vec(), Object::Ref(nested));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
let mut group = Dict::new();
|
||||
group.set(b"S".to_vec(), Object::name("Transparency"));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array([0, 0, 100, 50].map(Object::Int).to_vec()));
|
||||
form.set(b"Group".to_vec(), Object::Dict(group));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(res));
|
||||
let artwork = doc.add(Object::Stream(Stream::flate(form, b"/Inner Do")));
|
||||
doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |d| {
|
||||
let mut xo = Dict::new();
|
||||
xo.set(b"Art".to_vec(), Object::Ref(artwork));
|
||||
d.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(pdfcraft_cos::ObjRef::new(7, 0), Object::Stream(Stream::flate(Dict::new(), b"q /Art Do Q")));
|
||||
let mut doc = reopen(&doc);
|
||||
let r = images::change_image(&mut doc, 0, 0, &images::ImageChange::Ungroup);
|
||||
assert!(matches!(r, Err(EditError::Invalid(ref m)) if m.contains("blend")), "{r:?}");
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! 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
|
||||
//! 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.
|
||||
//!
|
||||
//! Drawing a form inline paints the same pixels unless the form is a transparency group whose
|
||||
//! compositing differs from painting its parts one by one: a knockout group, a group that uses
|
||||
//! 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). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
|
||||
/// 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).
|
||||
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.
|
||||
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> {
|
||||
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"))?;
|
||||
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||||
return Err(refuse("it is not a Form XObject"));
|
||||
}
|
||||
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
|
||||
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
};
|
||||
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
None => None,
|
||||
Some(_) => Some(Matrix(
|
||||
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
|
||||
.map_err(|_| refuse("its matrix is unreadable"))?,
|
||||
)),
|
||||
};
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
|
||||
let form_res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
|
||||
// A tagged form's content is a structure element's: drawn inline it would lose that link.
|
||||
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
|
||||
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
}
|
||||
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| g.name(b"S") == Some(b"Transparency"));
|
||||
if let Some(group) = &group {
|
||||
if matches!(group.get(b"K"), Some(Object::Bool(true))) {
|
||||
return Err(refuse("it is a knockout transparency group"));
|
||||
}
|
||||
if translucent {
|
||||
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly: damaged data must not be copied in as a shorter form.
|
||||
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Err(refuse("its content has parts that can't be read"));
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
if group.is_some() {
|
||||
// The form itself is being read: drawing itself again counts as blending.
|
||||
let mut seen: Vec<pdfcraft_cos::ObjRef> = form.as_ref().into_iter().collect();
|
||||
if blends_inside(doc, &form_res, &ops, 0, &mut seen) {
|
||||
return Err(refuse("it is a transparency group with blend modes inside"));
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the form's resources into a copy of the page's, renaming what clashes.
|
||||
let mut merged = resources.clone();
|
||||
let mut renames: Vec<(&[u8], Renames)> = Vec::new();
|
||||
if own.is_some() {
|
||||
for cat in CATEGORIES {
|
||||
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut map = Renames::new();
|
||||
let mut n = 0u32;
|
||||
for (key, value) in theirs.iter() {
|
||||
if mine.get(key) == Some(value) {
|
||||
continue;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUg{n}").into_bytes();
|
||||
if mine.get(&candidate).is_none() && theirs.get(&candidate).is_none() {
|
||||
break candidate;
|
||||
}
|
||||
if n == u32::MAX {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
};
|
||||
mine.set(fresh.clone(), value.clone());
|
||||
map.push((key.to_vec(), fresh));
|
||||
}
|
||||
merged.set(cat.to_vec(), Object::Dict(mine));
|
||||
if !map.is_empty() {
|
||||
renames.push((cat, map));
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed = |cat: &[u8], name: &[u8]| -> Option<Vec<u8>> {
|
||||
renames.iter().find(|(c, _)| *c == cat).and_then(|(_, m)| m.iter().find(|(from, _)| from == name)).map(|(_, to)| to.clone())
|
||||
};
|
||||
let mut depth = 0usize;
|
||||
for op in &mut ops {
|
||||
let slot: Option<(&[u8], usize)> = match op.op.as_slice() {
|
||||
b"Tf" | b"Do" | b"gs" | b"sh" | b"cs" | b"CS" => {
|
||||
let cat: &[u8] = match op.op.as_slice() {
|
||||
b"Tf" => b"Font",
|
||||
b"Do" => b"XObject",
|
||||
b"gs" => b"ExtGState",
|
||||
b"sh" => b"Shading",
|
||||
_ => b"ColorSpace",
|
||||
};
|
||||
Some((cat, 0))
|
||||
}
|
||||
b"scn" | b"SCN" => op.operands.len().checked_sub(1).map(|i| (&b"Pattern"[..], i)),
|
||||
b"BDC" | b"DP" => Some((&b"Properties"[..], 1)),
|
||||
b"q" => {
|
||||
depth = depth.saturating_add(1);
|
||||
None
|
||||
}
|
||||
b"Q" => {
|
||||
depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((cat, i)) = slot
|
||||
&& let Some(operand) = op.operands.get_mut(i)
|
||||
&& let Object::Name(n) = operand
|
||||
&& let Some(to) = renamed(cat, n)
|
||||
{
|
||||
*operand = Object::Name(to);
|
||||
}
|
||||
if let Some((dict, _)) = &mut op.inline {
|
||||
for key in [&b"CS"[..], b"ColorSpace"] {
|
||||
if let Some(Object::Name(n)) = dict.get(key)
|
||||
&& let Some(to) = renamed(b"ColorSpace", n)
|
||||
{
|
||||
dict.set(key.to_vec(), Object::Name(to));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
Some(oc) => {
|
||||
let mut props = merged.get(b"Properties").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let existing = props.iter().find(|(_, v)| *v == oc).map(|(k, _)| k.clone());
|
||||
let name = match existing {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
let fresh = (1..=u32::MAX)
|
||||
.map(|n| format!("PCUgOC{n}").into_bytes())
|
||||
.find(|c| props.get(c).is_none())
|
||||
.ok_or_else(|| refuse("too many resources"))?;
|
||||
props.set(fresh.clone(), oc.clone());
|
||||
merged.set(b"Properties".to_vec(), Object::Dict(props));
|
||||
fresh
|
||||
}
|
||||
};
|
||||
Some(name)
|
||||
}
|
||||
};
|
||||
|
||||
let num = |v: f64| Object::Real(v);
|
||||
let mut out = Vec::new();
|
||||
if let Some(name) = &layer {
|
||||
out.push(Op::new("BDC", vec![Object::name("OC"), Object::Name(name.clone())]));
|
||||
}
|
||||
out.push(Op::new("q", Vec::new()));
|
||||
if let Some(m) = matrix {
|
||||
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
|
||||
}
|
||||
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
|
||||
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
|
||||
out.push(Op::new("W", Vec::new()));
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
out.extend(ops);
|
||||
// A form ends with the state it was drawn with (the Do saves and restores it).
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
out.push(Op::new("Q", Vec::new()));
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok((out, merged))
|
||||
}
|
||||
|
||||
/// How deep [`blends_inside`] follows nested forms; deeper ones count as blending.
|
||||
const MAX_NESTING: usize = 8;
|
||||
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
|
||||
/// use, or one used by a form they draw, at any depth. A nested form that can't be read, is
|
||||
/// nested too deep or draws itself counts as blending, so the caller refuses rather than guesses.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, seen: &mut Vec<pdfcraft_cos::ObjRef>) -> bool {
|
||||
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (states, xobjects) = (category(b"ExtGState"), category(b"XObject"));
|
||||
for op in ops {
|
||||
if op.is("gs")
|
||||
&& op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if !op.is("Do") {
|
||||
continue;
|
||||
}
|
||||
let Some(o) = op.name(0).and_then(|n| xobjects.get(n)) else { continue };
|
||||
let Object::Stream(nested) = &*doc.resolve(o) else { continue };
|
||||
if nested.dict.name(b"Subtype") != Some(b"Form") {
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_NESTING || o.as_ref().is_some_and(|r| seen.contains(&r)) {
|
||||
return true;
|
||||
}
|
||||
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
|
||||
let own = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_else(|| resources.clone());
|
||||
if let Some(r) = o.as_ref() {
|
||||
seen.push(r);
|
||||
}
|
||||
let inner = parse(&data);
|
||||
if inner.skipped > 0 {
|
||||
return true;
|
||||
}
|
||||
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, seen);
|
||||
if let Some(r) = o.as_ref() {
|
||||
seen.retain(|x| *x != r);
|
||||
}
|
||||
if blends {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether an ExtGState blends with something other than Normal.
|
||||
fn blends(doc: &Document, gs: &Dict) -> bool {
|
||||
let Some(bm) = gs.get(b"BM").map(|b| doc.resolve(b)) else { return false };
|
||||
let first = match &*bm {
|
||||
Object::Array(a) => a.first().map(|x| doc.resolve(x)),
|
||||
_ => Some(bm.clone()),
|
||||
};
|
||||
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
|
||||
}
|
||||
|
||||
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of stream
|
||||
/// `stream`, following `q`/`Q` and `gs` through the page's streams in order. Each `gs` changes
|
||||
/// only the parameters it sets.
|
||||
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, streams: &[Vec<Op>], stream: usize, at: usize) -> bool {
|
||||
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
|
||||
let mut stack: Vec<[bool; 4]> = Vec::new();
|
||||
let mut now = [false; 4];
|
||||
for (si, ops) in streams.iter().enumerate().take(stream.saturating_add(1)) {
|
||||
let end = if si == stream { at.min(ops.len()) } else { ops.len() };
|
||||
for op in ops.iter().take(end) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => stack.push(now),
|
||||
b"Q" => now = stack.pop().unwrap_or([false; 4]),
|
||||
b"gs" => {
|
||||
let Some(gs) = op.name(0).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
|
||||
continue;
|
||||
};
|
||||
let alpha = |key: &[u8]| gs.get(key).map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).map(|a| a < 1.0);
|
||||
if let Some(v) = alpha(b"ca") {
|
||||
now[0] = v;
|
||||
}
|
||||
if let Some(v) = alpha(b"CA") {
|
||||
now[1] = v;
|
||||
}
|
||||
if let Some(m) = gs.get(b"SMask").map(|v| doc.resolve(v)) {
|
||||
now[2] = m.as_name() != Some(b"None");
|
||||
}
|
||||
if gs.get(b"BM").is_some() {
|
||||
now[3] = blends(doc, &gs);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
now.iter().any(|v| *v)
|
||||
}
|
||||
@@ -20,6 +20,9 @@ pub enum ImageEdit {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
Delete,
|
||||
/// Grouped Form artwork: draw its content in place, so its text and pictures can be edited
|
||||
/// one by one (#2964).
|
||||
Ungroup,
|
||||
}
|
||||
|
||||
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
|
||||
|
||||
@@ -259,6 +259,7 @@ impl Edit {
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
ImageEdit::Ungroup => "Ungroup artwork",
|
||||
}),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
|
||||
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
|
||||
|
||||
@@ -556,6 +556,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
ImageEdit::Ungroup => pdfcraft_edit::ImageChange::Ungroup,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
}
|
||||
|
||||
@@ -2005,6 +2005,7 @@
|
||||
Rotate image تدوير الصورة
|
||||
Flip image قلب الصورة
|
||||
Delete image حذف الصورة
|
||||
Ungroup artwork فك تجميع العمل الفني
|
||||
Add header & footer إضافة رأس وتذييل
|
||||
Update header & footer تحديث الرأس والتذييل
|
||||
Update watermark تحديث العلامة المائية
|
||||
@@ -2788,6 +2789,7 @@ certificate Version الإصدار
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. استخدمه عندما تتجاهل الطابعة النص أو التظليل أو الشفافية. لا تعود المطبوعة نصًا قابلًا للبحث؛ ومع الطباعة بتدرج الرمادي تكون الصور رمادية.
|
||||
Crop Image قص الصورة
|
||||
Remove Crop إزالة القص
|
||||
Ungroup فك التجميع
|
||||
Trim the picture with its handles; Enter or Done applies قص الصورة بمقابضها؛ يطبّق Enter أو تم
|
||||
Redaction mark appearance مظهر علامة الحجب
|
||||
Outline colour: لون الحد الخارجي:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2032,6 +2032,7 @@
|
||||
Delete document JavaScript Изтрий JavaScript на документа
|
||||
Delete field Изтрий полето
|
||||
Delete image Изтрий изображението
|
||||
Ungroup artwork Разгрупиране на графиката
|
||||
Delete link Изтрий връзката
|
||||
Delete page Изтрий страницата
|
||||
Device colour is used, but there is no PDF/A output intent Използва се цвят, зависим от устройството, но няма изходно намерение за PDF/A
|
||||
@@ -2474,6 +2475,7 @@ animation effects Off Изключени
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Използвайте го, когато принтерът пропуска текст, сенки или прозрачност. Отпечатаното вече не е текст за търсене; с печат в сиво изображенията са сиви.
|
||||
Crop Image Изрязване на изображението
|
||||
Remove Crop Премахване на изрязването
|
||||
Ungroup Разгрупиране
|
||||
Trim the picture with its handles; Enter or Done applies Изрежете картината чрез дръжките; Enter или Готово прилага
|
||||
Redaction mark appearance Вид на маркировката за заличаване
|
||||
Outline colour: Цвят на контура:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -2055,6 +2055,7 @@ key Space Mezerník
|
||||
Rotate image Otočit obrázek
|
||||
Flip image Převrátit obrázek
|
||||
Delete image Odstranit obrázek
|
||||
Ungroup artwork Zrušit seskupení grafiky
|
||||
Add header & footer Přidat záhlaví a zápatí
|
||||
Update header & footer Aktualizovat záhlaví a zápatí
|
||||
Update watermark Aktualizovat vodoznak
|
||||
@@ -2585,6 +2586,7 @@ animation effects Off Vypnuto
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Použijte, když tiskárna vynechává text, stínování nebo průhlednost. Výtisk už není prohledávatelný text; s tiskem v odstínech šedi jsou obrázky šedé.
|
||||
Crop Image Oříznout obrázek
|
||||
Remove Crop Odebrat oříznutí
|
||||
Ungroup Zrušit seskupení
|
||||
Trim the picture with its handles; Enter or Done applies Ořízněte obrázek úchyty; Enter nebo Hotovo použije
|
||||
Redaction mark appearance Vzhled značky začernění
|
||||
Outline colour: Barva obrysu:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1941,6 +1941,7 @@
|
||||
Rotate image Bild drehen
|
||||
Flip image Bild spiegeln
|
||||
Delete image Bild löschen
|
||||
Ungroup artwork Gruppierung der Grafik aufheben
|
||||
Add header & footer Kopf- und Fußzeile hinzufügen
|
||||
Update header & footer Kopf- und Fußzeile aktualisieren
|
||||
Update watermark Wasserzeichen aktualisieren
|
||||
@@ -2512,6 +2513,7 @@ animation effects Off Aus
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Verwenden, wenn ein Drucker Text, Schattierungen oder Transparenz auslässt. Der Ausdruck ist kein durchsuchbarer Text mehr; mit Graustufendruck sind die Bilder grau.
|
||||
Crop Image Bild zuschneiden
|
||||
Remove Crop Zuschnitt entfernen
|
||||
Ungroup Gruppierung aufheben
|
||||
Trim the picture with its handles; Enter or Done applies Bild mit den Griffen zuschneiden; Eingabe oder Fertig übernimmt
|
||||
Redaction mark appearance Aussehen der Schwärzungsmarkierung
|
||||
Outline colour: Konturfarbe:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -1368,6 +1368,7 @@
|
||||
Rotate image Περιστροφή εικόνας
|
||||
Flip image Αναστροφή εικόνας
|
||||
Delete image Διαγραφή εικόνας
|
||||
Ungroup artwork Κατάργηση ομαδοποίησης γραφικού
|
||||
Add header & footer Προσθήκη κεφαλίδας και υποσέλιδου
|
||||
Update header & footer Ενημέρωση κεφαλίδας και υποσέλιδου
|
||||
Update watermark Ενημέρωση υδατογραφήματος
|
||||
@@ -2517,6 +2518,7 @@ animation effects Off Ανενεργά
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Χρησιμοποιήστε το όταν ένας εκτυπωτής παραλείπει κείμενο, σκίαση ή διαφάνεια. Η εκτύπωση δεν είναι πλέον κείμενο με δυνατότητα αναζήτησης· με εκτύπωση σε αποχρώσεις του γκρι, οι εικόνες είναι γκρι.
|
||||
Crop Image Περικοπή εικόνας
|
||||
Remove Crop Κατάργηση περικοπής
|
||||
Ungroup Κατάργηση ομαδοποίησης
|
||||
Trim the picture with its handles; Enter or Done applies Περικόψτε την εικόνα με τις λαβές της· Enter ή Τέλος για εφαρμογή
|
||||
Redaction mark appearance Εμφάνιση σήμανσης απαλοιφής
|
||||
Outline colour: Χρώμα περιγράμματος:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 490 and column 9.
|
@@ -2020,6 +2020,7 @@ action wizard Start Iniciar
|
||||
Rotate image Girar imagen
|
||||
Flip image Voltear imagen
|
||||
Delete image Eliminar imagen
|
||||
Ungroup artwork Desagrupar ilustración
|
||||
Add header & footer Añadir encabezado y pie de página
|
||||
Update header & footer Actualizar encabezado y pie de página
|
||||
Update watermark Actualizar marca de agua
|
||||
@@ -2654,6 +2655,7 @@ animation effects Off Desactivados
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Úsalo cuando una impresora omite texto, sombreados o transparencias. La impresión deja de ser texto que se puede buscar; con Imprimir en escala de grises, las imágenes son grises.
|
||||
Crop Image Recortar imagen
|
||||
Remove Crop Quitar recorte
|
||||
Ungroup Desagrupar
|
||||
Trim the picture with its handles; Enter or Done applies Recorta la imagen con sus tiradores; Intro o Hecho lo aplica
|
||||
Redaction mark appearance Aspecto de la marca de censura
|
||||
Outline colour: Color del contorno:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -1936,6 +1936,7 @@
|
||||
Rotate image Pivoter l'image
|
||||
Flip image Retourner l'image
|
||||
Delete image Supprimer l'image
|
||||
Ungroup artwork Dissocier l’illustration
|
||||
Add header & footer Ajouter un en-tête et un pied de page
|
||||
Update header & footer Mettre à jour l'en-tête et le pied de page
|
||||
Update watermark Mettre à jour le filigrane
|
||||
@@ -2511,6 +2512,7 @@ animation effects Off Désactivés
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. À utiliser quand une imprimante saute du texte, des dégradés ou de la transparence. L'impression n'est plus du texte consultable ; avec l'impression en niveaux de gris, les images sont grises.
|
||||
Crop Image Recadrer l'image
|
||||
Remove Crop Supprimer le recadrage
|
||||
Ungroup Dissocier
|
||||
Trim the picture with its handles; Enter or Done applies Recadrez l'image avec ses poignées ; Entrée ou Terminé applique
|
||||
Redaction mark appearance Apparence de la marque de biffure
|
||||
Outline colour: Couleur du contour :
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -1926,6 +1926,7 @@
|
||||
Rotate image Kép forgatása
|
||||
Flip image Kép tükrözése
|
||||
Delete image Kép törlése
|
||||
Ungroup artwork Grafika csoportbontása
|
||||
Add header & footer Fejléc és lábléc hozzáadása
|
||||
Update header & footer Fejléc és lábléc frissítése
|
||||
Update watermark Vízjel frissítése
|
||||
@@ -2510,6 +2511,7 @@ animation effects Off Ki
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Akkor használja, ha a nyomtató kihagy szöveget, árnyékolást vagy átlátszóságot. A nyomat már nem kereshető szöveg; szürkeárnyalatos nyomtatással a képek szürkék.
|
||||
Crop Image Kép körülvágása
|
||||
Remove Crop Körülvágás eltávolítása
|
||||
Ungroup Csoportbontás
|
||||
Trim the picture with its handles; Enter or Done applies Vágja körül a képet a fogantyúival; Enter vagy Kész alkalmazza
|
||||
Redaction mark appearance Kitakarási jelölés megjelenése
|
||||
Outline colour: Körvonal színe:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -2011,6 +2011,7 @@
|
||||
Rotate image Ruota immagine
|
||||
Flip image Capovolgi immagine
|
||||
Delete image Elimina immagine
|
||||
Ungroup artwork Separa grafica
|
||||
Add header & footer Aggiungi intestazione e piè di pagina
|
||||
Update header & footer Aggiorna intestazione e piè di pagina
|
||||
Update watermark Aggiorna filigrana
|
||||
@@ -2520,6 +2521,7 @@ animation effects Off Disattivati
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Usalo quando una stampante salta testo, sfumature o trasparenze. La stampa non è più testo ricercabile; con Stampa in scala di grigi le immagini sono grigie.
|
||||
Crop Image Ritaglia immagine
|
||||
Remove Crop Rimuovi ritaglio
|
||||
Ungroup Separa
|
||||
Trim the picture with its handles; Enter or Done applies Ritaglia l'immagine con le maniglie; Invio o Fatto applica
|
||||
Redaction mark appearance Aspetto del segno di redazione
|
||||
Outline colour: Colore contorno:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2018,6 +2018,7 @@
|
||||
Rotate image 画像を回転
|
||||
Flip image 画像を反転
|
||||
Delete image 画像を削除
|
||||
Ungroup artwork アートワークのグループ解除
|
||||
Add header & footer ヘッダーとフッターを追加
|
||||
Update header & footer ヘッダーとフッターを更新
|
||||
Update watermark 透かしを更新
|
||||
@@ -2511,6 +2512,7 @@ animation effects Off オフ
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. プリンターが文字、グラデーション、透明部分を印刷しないときに使います。印刷物は検索できる文字ではなくなります。グレースケール印刷と併用すると画像はグレーになります。
|
||||
Crop Image 画像を切り抜き
|
||||
Remove Crop 切り抜きを解除
|
||||
Ungroup グループ解除
|
||||
Trim the picture with its handles; Enter or Done applies ハンドルで画像を切り抜きます。Enter または完了で適用
|
||||
Redaction mark appearance 墨消しマークの外観
|
||||
Outline colour: 枠線の色:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1927,6 +1927,7 @@
|
||||
Rotate image Кескінді бұру
|
||||
Flip image Кескінді аудару
|
||||
Delete image Кескінді жою
|
||||
Ungroup artwork Графиканы топтан шығару
|
||||
Add header & footer Үстіңгі және астыңғы колонтитулдарды қосу
|
||||
Update header & footer Үстіңгі және астыңғы колонтитулдарды жаңарту
|
||||
Update watermark Су белгісін жаңарту
|
||||
@@ -2509,6 +2510,7 @@ animation effects Off Өшірулі
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Принтер мәтінді, көлеңкені немесе мөлдірлікті өткізіп жіберсе, қолданыңыз. Басылған құжат енді іздеуге болатын мәтін емес; сұр реңкпен басып шығарғанда суреттер сұр болады.
|
||||
Crop Image Суретті қию
|
||||
Remove Crop Қиюды алып тастау
|
||||
Ungroup Топтан шығару
|
||||
Trim the picture with its handles; Enter or Done applies Суретті тұтқаларымен қиыңыз; Enter немесе Дайын қолданады
|
||||
Redaction mark appearance Жасыру белгісінің көрінісі
|
||||
Outline colour: Контур түсі:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 886 and column 9.
|
@@ -2062,6 +2062,7 @@ comment menu Edit Redaguoti
|
||||
Delete document JavaScript Naikinti dokumento „JavaScript“ scenarijų
|
||||
Delete field Naikinti lauką
|
||||
Delete image Naikinti paveikslėlį
|
||||
Ungroup artwork Išgrupuoti grafiką
|
||||
Delete link Naikinti saitą
|
||||
Delete page Naikinti puslapį
|
||||
Delete selected fields Naikinti pasirinktus laukus
|
||||
@@ -2761,6 +2762,7 @@ key Space Tarpas
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Naudokite, kai spausdintuvas praleidžia tekstą, šešėliavimą ar skaidrumą. Atspaudas nebėra ieškomas tekstas; spausdinant pilkais atspalviais vaizdai yra pilki.
|
||||
Crop Image Apkarpyti vaizdą
|
||||
Remove Crop Pašalinti apkarpymą
|
||||
Ungroup Išgrupuoti
|
||||
Trim the picture with its handles; Enter or Done applies Apkarpykite paveikslėlį rankenėlėmis; Enter arba Atlikta pritaiko
|
||||
Redaction mark appearance Užtamsinimo žymės išvaizda
|
||||
Outline colour: Kontūro spalva:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 406 and column 9.
|
@@ -1999,6 +1999,7 @@
|
||||
Rotate image Obróć obraz
|
||||
Flip image Odbij obraz
|
||||
Delete image Usuń obraz
|
||||
Ungroup artwork Rozgrupuj grafikę
|
||||
Add header & footer Dodaj nagłówek i stopkę
|
||||
Update header & footer Zaktualizuj nagłówek i stopkę
|
||||
Update watermark Zaktualizuj znak wodny
|
||||
@@ -2487,6 +2488,7 @@ animation effects Off Wyłączone
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Użyj, gdy drukarka pomija tekst, cieniowanie lub przezroczystość. Wydruk nie jest już tekstem do przeszukiwania; przy druku w skali szarości obrazy są szare.
|
||||
Crop Image Przytnij obraz
|
||||
Remove Crop Usuń przycięcie
|
||||
Ungroup Rozgrupuj
|
||||
Trim the picture with its handles; Enter or Done applies Przytnij obraz uchwytami; Enter lub Gotowe stosuje
|
||||
Redaction mark appearance Wygląd znacznika redakcji
|
||||
Outline colour: Kolor obrysu:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1905,6 +1905,7 @@
|
||||
Delete document JavaScript Excluir JavaScript do documento
|
||||
Delete field Excluir campo
|
||||
Delete image Excluir imagem
|
||||
Ungroup artwork Desagrupar arte
|
||||
Delete link Excluir link
|
||||
Delete page Excluir página
|
||||
Device colour is used, but there is no PDF/A output intent A cor do dispositivo foi usada, mas não há intenção de saída PDF/A
|
||||
@@ -2525,6 +2526,7 @@ animation effects Off Desativados
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Use quando uma impressora omite texto, sombreamento ou transparência. A impressão deixa de ser texto pesquisável; com Imprimir em tons de cinza, as imagens ficam cinza.
|
||||
Crop Image Cortar imagem
|
||||
Remove Crop Remover corte
|
||||
Ungroup Desagrupar
|
||||
Trim the picture with its handles; Enter or Done applies Corte a imagem com as alças; Enter ou Concluído aplica
|
||||
Redaction mark appearance Aparência da marca de redação
|
||||
Outline colour: Cor do contorno:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2006,6 +2006,7 @@
|
||||
Rotate image Повернуть изображение
|
||||
Flip image Отразить изображение
|
||||
Delete image Удалить изображение
|
||||
Ungroup artwork Разгруппировать графику
|
||||
Add header & footer Добавить колонтитулы
|
||||
Update header & footer Обновить колонтитулы
|
||||
Update watermark Обновить водяной знак
|
||||
@@ -2424,6 +2425,7 @@ animation effects Off Выкл
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Используйте, когда принтер пропускает текст, заливки или прозрачность. Отпечаток больше не текст с поиском; при печати в оттенках серого изображения серые.
|
||||
Crop Image Обрезать изображение
|
||||
Remove Crop Убрать обрезку
|
||||
Ungroup Разгруппировать
|
||||
Trim the picture with its handles; Enter or Done applies Обрежьте изображение за маркеры; Enter или «Готово» применяет
|
||||
Redaction mark appearance Вид метки скрытия
|
||||
Outline colour: Цвет контура:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -557,6 +557,7 @@
|
||||
Delete document JavaScript Izbriši JavaScript dokumenta
|
||||
Delete field Izbriši polje
|
||||
Delete image Izbriši sliko
|
||||
Ungroup artwork Razdruži grafiko
|
||||
Delete link Izbriši povezavo
|
||||
Delete page Izbriši stran
|
||||
Delete pages Izbriši strani
|
||||
@@ -2516,6 +2517,7 @@ animation effects Off Izklopljeni
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Uporabite, ko tiskalnik izpusti besedilo, senčenje ali prosojnost. Natis ni več iskalno besedilo; s tiskanjem v sivinah so slike sive.
|
||||
Crop Image Obreži sliko
|
||||
Remove Crop Odstrani obrezovanje
|
||||
Ungroup Razdruži
|
||||
Trim the picture with its handles; Enter or Done applies Obrežite sliko z ročicami; Enter ali Končano uporabi
|
||||
Redaction mark appearance Videz oznake za zakritje
|
||||
Outline colour: Barva obrobe:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -1925,6 +1925,7 @@ action wizard Start ప్రారంభించు
|
||||
Rotate image చిత్రాన్ని తిప్పు
|
||||
Flip image చిత్రాన్ని తిరగేయి
|
||||
Delete image చిత్రాన్ని తొలగించు
|
||||
Ungroup artwork కళాకృతి సమూహాన్ని విడదీయండి
|
||||
Add header & footer హెడర్ & ఫుటర్ జోడించు
|
||||
Update header & footer హెడర్ & ఫుటర్ను అప్డేట్ చేయి
|
||||
Update watermark వాటర్మార్క్ను అప్డేట్ చేయి
|
||||
@@ -2420,6 +2421,7 @@ animation effects Off ఆఫ్
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. ప్రింటర్ వచనం, షేడింగ్ లేదా పారదర్శకతను వదిలేసినప్పుడు దీన్ని వాడండి. ముద్రణ ఇక శోధించగల వచనం కాదు; బూడిద రంగులో ముద్రించినప్పుడు చిత్రాలు బూడిద రంగులో ఉంటాయి.
|
||||
Crop Image చిత్రాన్ని కత్తిరించు
|
||||
Remove Crop కత్తిరింపును తీసివేయి
|
||||
Ungroup సమూహాన్ని విడదీయండి
|
||||
Trim the picture with its handles; Enter or Done applies హ్యాండిల్లతో చిత్రాన్ని కత్తిరించండి; Enter లేదా పూర్తయింది వర్తింపజేస్తుంది
|
||||
Redaction mark appearance రెడాక్షన్ గుర్తు రూపం
|
||||
Outline colour: అంచు రంగు:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
@@ -1899,6 +1899,7 @@
|
||||
Rotate image Resmi döndür
|
||||
Flip image Resmi çevir
|
||||
Delete image Resmi sil
|
||||
Ungroup artwork Çizimin grubunu çöz
|
||||
Add header & footer Üstbilgi ve altbilgi ekle
|
||||
Update header & footer Üstbilgi ve altbilgiyi güncelle
|
||||
Update watermark Filigranı güncelle
|
||||
@@ -2507,6 +2508,7 @@ animation effects Off Kapalı
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Yazıcı metni, gölgelendirmeyi veya saydamlığı atladığında kullanın. Çıktı artık aranabilir metin değildir; gri tonlamalı yazdırmayla resimler gri olur.
|
||||
Crop Image Resmi kırp
|
||||
Remove Crop Kırpmayı kaldır
|
||||
Ungroup Grubu çöz
|
||||
Trim the picture with its handles; Enter or Done applies Resmi tutamaçlarıyla kırpın; Enter veya Bitti uygular
|
||||
Redaction mark appearance Karartma işareti görünümü
|
||||
Outline colour: Kenar rengi:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 885 and column 9.
|
@@ -2003,6 +2003,7 @@
|
||||
Rotate image Повернути зображення
|
||||
Flip image Віддзеркалити зображення
|
||||
Delete image Видалити зображення
|
||||
Ungroup artwork Розгрупувати графіку
|
||||
Add header & footer Додати колонтитули
|
||||
Update header & footer Оновити колонтитули
|
||||
Update watermark Оновити водяний знак
|
||||
@@ -2533,6 +2534,7 @@ animation effects Off Вимкнено
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. Використовуйте, коли принтер пропускає текст, заливки або прозорість. Відбиток більше не текст із пошуком; під час друку у відтінках сірого зображення сірі.
|
||||
Crop Image Обрізати зображення
|
||||
Remove Crop Прибрати обрізання
|
||||
Ungroup Розгрупувати
|
||||
Trim the picture with its handles; Enter or Done applies Обріжте зображення за маркери; Enter або «Готово» застосовує
|
||||
Redaction mark appearance Вигляд позначки приховування
|
||||
Outline colour: Колір контуру:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -2050,6 +2050,7 @@ comment menu Edit Sửa
|
||||
Delete document JavaScript Xóa JavaScript của tài liệu
|
||||
Delete field Xóa trường
|
||||
Delete image Xóa hình ảnh
|
||||
Ungroup artwork Rã nhóm hình vẽ
|
||||
Delete link Xóa liên kết
|
||||
Delete page Xóa trang
|
||||
Delete selected fields Xóa các trường đã chọn
|
||||
@@ -2757,6 +2758,7 @@ key Space Space
|
||||
Reason: Lý do:
|
||||
Redaction mark appearance Hình thức dấu xóa nội dung nhạy cảm
|
||||
Remove Crop Bỏ xén
|
||||
Ungroup Rã nhóm
|
||||
Remove this leaf tag from reading order and mark its direct content as an artifact Xóa thẻ lá này khỏi thứ tự đọc và đánh dấu nội dung trực tiếp của nó là thành phần trang trí
|
||||
Remove {standard} identification Gỡ thông tin nhận dạng {standard}
|
||||
Resolution: Độ phân giải:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 493 and column 12.
|
@@ -2042,6 +2042,7 @@
|
||||
Delete document JavaScript 删除文档 JavaScript
|
||||
Delete field 删除字段
|
||||
Delete image 删除图像
|
||||
Ungroup artwork 取消组合图稿
|
||||
Delete link 删除链接
|
||||
Delete page 删除页面
|
||||
Device colour is used, but there is no PDF/A output intent 使用了设备颜色,但未设置 PDF/A 输出意图
|
||||
@@ -2475,6 +2476,7 @@ animation effects Off 关
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 当打印机漏印文字、渐变或透明效果时使用。打印结果不再是可搜索的文字;配合灰度打印时图像为灰色。
|
||||
Crop Image 裁剪图像
|
||||
Remove Crop 移除裁剪
|
||||
Ungroup 取消组合
|
||||
Trim the picture with its handles; Enter or Done applies 用控制柄裁剪图片;按 Enter 或“完成”应用
|
||||
Redaction mark appearance 涂黑标记外观
|
||||
Outline colour: 轮廓颜色:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2005,6 +2005,7 @@
|
||||
Rotate image 旋轉圖片
|
||||
Flip image 翻轉圖片
|
||||
Delete image 刪除圖片
|
||||
Ungroup artwork 取消群組圖稿
|
||||
Add header & footer 新增頁首與頁尾
|
||||
Update header & footer 更新頁首與頁尾
|
||||
Update watermark 更新浮水印
|
||||
@@ -2461,6 +2462,7 @@ animation effects Off 關
|
||||
Use it when a printer skips text, shading or transparency. The print is no longer searchable text; with Print in grayscale, the pictures are grey. 當印表機漏印文字、漸層或透明效果時使用。列印結果不再是可搜尋的文字;搭配灰階列印時影像為灰色。
|
||||
Crop Image 裁切影像
|
||||
Remove Crop 移除裁切
|
||||
Ungroup 取消群組
|
||||
Trim the picture with its handles; Enter or Done applies 用控點裁切圖片;按 Enter 或「完成」套用
|
||||
Redaction mark appearance 塗黑標記外觀
|
||||
Outline colour: 輪廓色彩:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -657,6 +657,13 @@ fn image_menu(
|
||||
ui.close();
|
||||
}
|
||||
let raster = images.get(hit).is_some_and(|image| !image.is_form);
|
||||
// Grouped Form artwork: draw its content in place, so its text and pictures edit one by
|
||||
// one (#2964, #3005).
|
||||
if images.get(hit).is_some_and(|image| image.is_form) && ui.button(tl!("Ungroup")).clicked() {
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::EditPageImage { page, index: hit, change: E::Ungroup });
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(raster, egui::Button::new(tl!("Replace Image…"))).clicked() {
|
||||
*action = Some(ImageAction::Replace(page, hit));
|
||||
ui.close();
|
||||
|
||||
@@ -7554,3 +7554,66 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
|
||||
assert_eq!(blocks(&h)[0].text, "Page 1");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3005: a table drawn inside one Form XObject is a single piece of artwork in Edit text &
|
||||
/// images. Its right-click menu offers Ungroup, which draws the form in place so the table's
|
||||
/// text can be edited; one Undo puts the form back.
|
||||
#[test]
|
||||
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
|
||||
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
|
||||
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
|
||||
stream("<<", b"q /Fm1 Do Q"),
|
||||
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("table.pdf", None, pdf.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let blocks = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().text_blocks(0).len();
|
||||
assert_eq!(blocks(&h), 0, "the table's text is inside the form");
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(2);
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
// An empty spot of the form: the artwork, not a paragraph.
|
||||
let at = r.left_top() + egui::vec2(r.width() * 0.8, r.height() * 0.85);
|
||||
right_click_at(&mut h, at);
|
||||
h.run_steps(1);
|
||||
let selection = h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index));
|
||||
assert_eq!(selection, Some((0, 0)), "the right-click selects the form");
|
||||
assert!(h.query_by_label("Ungroup").is_some(), "the artwork menu offers Ungroup");
|
||||
// The page's own menu opens over the artwork menu (#2637, fixed by #2155), so the item is
|
||||
// applied the way it queues its edit rather than clicked through the overlap.
|
||||
h.state_mut().views[0].pending_edit =
|
||||
Some(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::Ungroup });
|
||||
h.run_steps(4);
|
||||
assert!(blocks(&h) >= 1, "the table's rows are page text now");
|
||||
let images = h.state().session.get(id).unwrap().page_images(0);
|
||||
assert!(images.is_empty(), "no artwork left: {images:?}");
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(blocks(&h), 0, "undo puts the form back");
|
||||
}
|
||||
@@ -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"]
|
||||
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"
|
||||
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."
|
||||
|
||||
[[feature]]
|
||||
id = "edit.text-reflow"
|
||||
|
||||
Reference in new issue
Block a user