diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index e425d00328..d3c0e4fdcd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -10947,6 +10947,80 @@ fn grouped_image_artwork_is_not_prepared() { assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "inner")); } +/// Artwork and groups refused for what they are (knockout groups, content with an operand too +/// many) are read once, not again on every pass: 10 logo forms and 30 such groups beside a text +/// group are 41 reads, within the 64-placement limit, where reading either again on the second +/// pass would pass it and prepare nothing. +#[test] +fn artwork_and_refused_groups_count_once_against_the_placement_limit() { + for knockout in [true, false] { + let refused_body = if knockout { "BT /F1 14 Tf 40 500 Td (kept) Tj ET" } else { "BT /F1 14 Tf 40 500 Td (kept) Tj ET 0 /Im Do" }; + let mut forms: Vec<(String, String)> = (1..=10).map(|i| (format!("Logo{i}"), "q 10 0 0 10 0 0 cm /Im Do Q".to_string())).collect(); + forms.extend((1..=30).map(|i| (format!("Refused{i}"), refused_body.to_string()))); + forms.push(("Body".into(), "BT /F1 14 Tf 40 300 Td (body) Tj ET".into())); + let content: String = forms.iter().map(|(name, _)| format!("/{name} Do\n")).collect(); + let mut doc = page_with_forms(&content, &forms); + // Forms are objects 7 onwards, in order: the refused groups follow the 10 logos. + for i in 0..30u32 { + let r = pdfcraft_cos::ObjRef::new(7 + 10 + i, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + if knockout { + let group = build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + s.dict.set(b"Group".to_vec(), Object::Dict(group)); + } + doc.set(r, Object::Stream(s)); + } + let result = text::forms::prepare(&mut doc, 0).unwrap_or_else(|e| panic!("knockout {knockout}: each group is read once ({e})")); + assert_eq!(result.expanded, 1); + assert_eq!(result.refused.len(), 30, "{:?}", result.refused); + assert!(text::text_blocks(&doc, 0).unwrap().iter().any(|b| b.text == "body")); + } +} + +/// A page-level `Do` with an operand too many is left as it is and settles nothing: the form +/// found by its first operand may not be the one the renderer draws (its last). Here `/A /B Do` +/// draws the knockout group B, and A, a text group, is still prepared where Parent draws it. +#[test] +fn a_page_do_with_an_operand_too_many_settles_no_form() { + let forms = [ + ("A".to_string(), "BT /F1 14 Tf 40 500 Td (alpha) Tj ET".to_string()), + ("B".to_string(), "BT /F1 14 Tf 40 400 Td (beta) Tj ET".to_string()), + ("Parent".to_string(), "/A Do".to_string()), + ]; + let mut doc = page_with_forms("/A /B Do\n/Parent Do", &forms); + let r = pdfcraft_cos::ObjRef::new(8, 0); + let Object::Stream(mut s) = doc.get(r).as_ref().clone() else { panic!("fixture") }; + let group = build(&["<< /S /Transparency /K true >>"]).get(pdfcraft_cos::ObjRef::new(1, 0)).as_dict().unwrap().clone(); + s.dict.set(b"Group".to_vec(), Object::Dict(group)); + doc.set(r, Object::Stream(s)); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 2, "Parent, then the A it draws: {:?}", result.refused); + assert!(result.refused.iter().any(|r| r.contains("an operand too many")), "{:?}", result.refused); +} + +/// A refusal that comes from where a form is drawn is not remembered for the form: a text group +/// refused where it is drawn translucent is still prepared where an expanded parent draws it +/// opaque, on the next pass. +#[test] +fn a_placement_refusal_does_not_settle_the_form_for_later_passes() { + let resources = "<< /Font << /F1 5 0 R >> /ExtGState << /Alpha << /ca 0.5 >> >> /XObject << /Shared 6 0 R /Parent 7 0 R >> >>"; + let form = |extra: &str, body: &str| stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 612 792] {extra} /Resources {resources}"), body); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources {resources} /Contents 4 0 R >>"), + stream("", "q /Alpha gs /Shared Do Q /Parent Do"), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + form("/Group << /S /Transparency >>", "BT /F1 14 Tf 40 500 Td (shared) Tj ET"), + form("", "/Shared Do"), + ]; + let mut doc = build(&objs.iter().map(String::as_str).collect::>()); + let result = text::forms::prepare(&mut doc, 0).unwrap(); + assert_eq!(result.expanded, 2, "Parent, then the Shared it draws opaque: {:?}", result.refused); + assert!(result.refused.iter().any(|r| r.starts_with("Shared: ") && r.contains("translucent")), "{:?}", result.refused); + assert_eq!(text::text_blocks(&doc, 0).unwrap().iter().filter(|b| b.text == "shared").count(), 1, "only the opaque one is the page's own text"); +} + /// The `Do` is matched to its resource by the name's bytes as written, which need not be UTF-8. #[test] fn grouped_text_under_a_non_utf8_resource_name_is_prepared() { diff --git a/crates/edit/src/text/forms.rs b/crates/edit/src/text/forms.rs index dff7440145..da46a16d91 100644 --- a/crates/edit/src/text/forms.rs +++ b/crates/edit/src/text/forms.rs @@ -32,6 +32,11 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result Result pending_path = true, @@ -59,23 +65,33 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result {} } let Some(image) = images.get(&at) else { continue }; - visits = visits.saturating_add(1); - if visits > 64 { - return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + if image.object.is_some_and(|o| settled.contains(&o)) { + continue; } if pending_path || in_text { result.refused.push(format!("{}: the group is drawn inside an unfinished path or text object", image.name)); continue; } + // The renderer draws a `Do`'s last operand; `image` was found by its first. With more + // than one they can be different forms, so this placement is left as it is. + if op.operands.len() != 1 { + result.refused.push(format!("{}: the group is drawn with an operand too many", image.name)); + continue; + } + visits = visits.saturating_add(1); + if visits > 64 { + return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into())); + } let translucent = expand::translucent_at(&next, &merged, &ops, at); - // The resource name exactly as the `Do` writes it: a name need not be UTF-8, and - // `image.name` is only its display form. + // The resource name exactly as the `Do` writes it (its only operand): a name need not + // be UTF-8, and `image.name` is only its display form. let name = op.last_name().map_or_else(|| image.name.as_bytes().to_vec(), <[u8]>::to_vec); match expand::ungroup_ops(&next, &merged, &name, translucent, space.as_ref(), Matrix(image.matrix), &mut decode) { Ok(expand::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => { // Vector and image artwork needs no preparation. Nested text groups are // visited on the next pass, with their inherited placement and clip in force. if !shows_text { + settled.extend(image.object); continue; } if pass == MAX_LEVELS { @@ -96,10 +112,17 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result { + result.refused.push(format!("{}: {}", image.name, refusal.error)); + if refusal.intrinsic { + refused_here.extend(image.object); + } } - Err(error) => result.refused.push(format!("{}: {error}", image.name)), } } + settled.extend(refused_here.difference(&expanded_here).copied()); if replacements.is_empty() { break; } diff --git a/crates/edit/src/text/forms/expand.rs b/crates/edit/src/text/forms/expand.rs index bf01e201d1..54495900cc 100644 --- a/crates/edit/src/text/forms/expand.rs +++ b/crates/edit/src/text/forms/expand.rs @@ -59,6 +59,16 @@ pub(crate) struct Ungrouped { pub(crate) shows_text: bool, } +/// Why [`ungroup_ops`] left a placement grouped. +pub(crate) struct Refusal { + pub(crate) error: EditError, + /// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every + /// placement of it is refused the same way. Otherwise something about this placement or the + /// page so far is: its translucency or matrix, the resources it would merge into (which grow + /// as other groups are prepared), or the decode budget left. + pub(crate) intrinsic: bool, +} + /// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state /// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a /// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]). @@ -72,8 +82,12 @@ pub(crate) fn ungroup_ops( page_space: Option<&Object>, placement: Matrix, budget: &mut DecodeBudget, -) -> Result { - let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}")); +) -> Result { + let refusal = |why: &str, intrinsic: bool| Refusal { error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")), intrinsic }; + // Something about this placement or the page so far is the reason. + let refuse = |why: &str| refusal(why, false); + // The form itself is the reason: every placement of it is refused the same way. + let invalid = |why: &str| refusal(why, true); 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")) }; @@ -88,32 +102,32 @@ pub(crate) fn ungroup_ops( let v: Vec = 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 bbox = numbers(b"BBox", 4).ok_or_else(|| invalid("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"))?, + <[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| invalid("its matrix is unreadable"))?) + .map_err(|_| invalid("its matrix is unreadable"))?, )), }; let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); if s.dict.get(b"Resources").is_some() && own.is_none() { - return Err(refuse("its resources cannot be read")); + return Err(invalid("its resources cannot be read")); } // 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")); + return Err(invalid("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| is_name(doc, g, b"S", b"Transparency")); if s.dict.get(b"Group").is_some() && group.is_none() { - return Err(refuse("its group dictionary cannot be read")); + return Err(invalid("its group dictionary cannot be read")); } if let Some(group) = &group { if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) { - return Err(refuse("it is a knockout transparency group")); + return Err(invalid("it is a knockout transparency group")); } if translucent { return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask")); @@ -123,7 +137,7 @@ pub(crate) fn ungroup_ops( if let Some(cs) = group.get(b"CS") && !same_blend_space(doc, cs, page_space) { - return Err(refuse("it is a transparency group that blends in a colour space of its own")); + return Err(invalid("it is a transparency group that blends in a colour space of its own")); } } @@ -131,9 +145,17 @@ pub(crate) fn ungroup_ops( let data = budget.decode_strict(s).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")); + return Err(invalid("its content has parts that can't be read")); } let mut ops = parsed.ops; + // A name is renamed at a fixed operand position below, but the renderer takes operands from + // the end and ignores surplus ones before them: with a surplus operand the drawn name would + // keep its old spelling while its resource moved, and draw nothing or the wrong thing. The + // parser keeps such operands without counting them as skipped. Checked before the nested + // walk, which can only call it unverifiable. + if ops.iter().any(|op| takes_operands(&op.op).is_some_and(|n| op.operands.len() > n)) { + return Err(invalid("its content gives an operator more operands than it takes")); + } let shows_text = { // Check every form, including non-group forms, for recursive/unsupported content. // The form itself is being read: drawing itself again counts as blending. @@ -151,7 +173,7 @@ pub(crate) fn ungroup_ops( 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 }; if theirs.len() > 4096 { - return Err(refuse("too many resources")); + return Err(invalid("too many resources")); } let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); if mine.len() > 4096 { @@ -203,20 +225,13 @@ pub(crate) fn ungroup_ops( for op in &mut ops { match op.op.as_slice() { b"BDC" | b"BMC" => marked = marked.saturating_add(1), - b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?, - b"BT" if in_text => return Err(refuse("its content begins a text object inside another")), + b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?, + b"BT" if in_text => return Err(invalid("its content begins a text object inside another")), b"BT" => in_text = true, - b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")), + b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")), b"ET" => in_text = false, _ => {} } - // A name is renamed at the operand position below, but the renderer takes operands from - // the end and ignores surplus ones before them: with a surplus operand the drawn name - // would keep its old spelling while its resource moved, and draw nothing or the wrong - // thing. The parser keeps such operands without counting them as skipped. - if takes_operands(&op.op).is_some_and(|n| op.operands.len() > n) { - return Err(refuse("its content gives an operator more operands than it takes")); - } 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() { @@ -232,13 +247,13 @@ pub(crate) fn ungroup_ops( b"BDC" | b"DP" => Some((&b"Properties"[..], 1)), b"q" => { if depth >= 4096 { - return Err(refuse("graphics state is too deeply nested")); + return Err(invalid("graphics state is too deeply nested")); } depth += 1; None } b"Q" => { - depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?; + depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?; None } _ => None, @@ -262,7 +277,7 @@ pub(crate) fn ungroup_ops( } if marked > 0 || in_text { - return Err(refuse("its content leaves marked content or a text object open")); + return Err(invalid("its content leaves marked content or a text object open")); } // Patterns keep their place relative to the shapes they fill: each one the form selects is @@ -351,7 +366,7 @@ pub(crate) fn ungroup_ops( if let Some(m) = matrix { out.push(Op::new("cm", m.0.iter().copied().map(num).collect())); } - let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| refuse("invalid form bounds"))?; + let [a, b, c, d] = <[f64; 4]>::try_from(bbox).map_err(|_| invalid("invalid form bounds"))?; let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d)); out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)])); out.push(Op::new("W", Vec::new())); diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index 8596d58734..0f950afc6d 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -120,7 +120,8 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option Option). +const PUA_BASES: [char; 27] = ['ฐ', 'ิ', 'ี', 'ึ', 'ื', '่', '้', '๊', '๋', '์', '่', '้', '๊', '๋', '์', 'ญ', 'ั', 'ํ', '็', '่', '้', '๊', '๋', '์', 'ุ', 'ู', 'ฺ']; + /// Whether a Thai character is a vowel or tone mark set above or below its base, with no advance /// of its own (U+0E31, U+0E34–U+0E3A, U+0E47–U+0E4E). Sara Am and the other vowels advance. fn is_thai_mark(ch: char) -> bool { @@ -236,17 +242,30 @@ pub(super) fn composite_glyph_maps( let kept: Vec<(usize, u32)> = (342u32..=368).enumerate().filter(|&(_, gid)| outlined(gid)).collect(); let in_order = kept.iter().all(|&(index, gid)| advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| (w > 0.0) == matches!(index, 0 | 15))); + // Where the PDF says what an alternate's code is (an intact ToUnicode gives the character it + // stands for), it must be that alternate's base character: two alternates of the same kind + // in another order pass the advance check, but not this one. + let code_of = |gid: u32| match &cid_map { + None => u16::try_from(gid).ok(), + Some(_) => codes.get(&gid).copied(), + }; + let meanings_agree = kept.iter().all(|&(index, gid)| { + let stated = code_of(gid).and_then(|code| metrics.text_of(u32::from(code))).and_then(|t| only_char(&t)); + stated.filter(|c| matches!(u32::from(*c), 0x0e01..=0x0e5b)).is_none_or(|c| PUA_BASES.get(index) == Some(&c)) + }); if family.starts_with("THSarabunPSK") && unicode.get(&0x0e01) == Some(&214) && unicode.get(&0x0e2d) == Some(&258) && (alternate_meanings || unicode.contains_key(&0xf70a)) && in_order + && meanings_agree { for (index, gid) in kept { - if let Some(code) = match &cid_map { + let code = match &cid_map { None => u16::try_from(gid).ok(), Some(_) => codes.get(&gid).copied(), - } { + }; + if let Some(code) = code { unicode.insert(0xf700 + index as u32, gid); codes.entry(gid).or_insert(code); } @@ -555,13 +574,14 @@ mod tests { #[test] fn legacy_thai_alternates_need_their_glyph_order_confirmed() { let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar"; - let alternates = |program: &[u8]| { + let alternates_with = |program: &[u8], unicode: &[u8]| { let mut doc = Document::new_empty(); let font = thai_font(&mut doc, program, unicode); let metrics = Metrics::from_dict(&doc, &font); let (map, _) = composite_glyph_maps(&doc, &font, program, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap(); map.range(0xf700..=0xf71a).map(|(u, g)| (*u, *g)).collect::>() }; + let alternates = |program: &[u8]| alternates_with(program, unicode); let ordered = alternates(&thai_ordered_program()); assert!(ordered.contains(&(0xf70a, 352)) && ordered.contains(&(0xf710, 358)), "{ordered:?}"); let mut misplaced = thai_ordered_program(); @@ -572,6 +592,12 @@ mod tests { drop_glyph(&mut dropped, 353); let kept = alternates(&dropped); assert!(kept.contains(&(0xf70a, 352)) && !kept.iter().any(|(_, g)| *g == 353), "{kept:?}"); + // Two marks of the same kind (both zero-advance) in another order: the PDF's own map says + // glyph 343 is ี and 344 is ิ, the reverse of U+F701 and U+F702. None is used. + let stated = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e35> <0158> <0e34> <0160> <0e48> <0161> <0e49> endbfchar"; + assert!(alternates_with(&thai_ordered_program(), stated).is_empty(), "a stated meaning out of the PUA order withdraws them all"); + let agreeing = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e34> <0158> <0e35> <0160> <0e48> <0161> <0e49> endbfchar"; + assert!(alternates_with(&thai_ordered_program(), agreeing).contains(&(0xf701, 343))); } #[test] diff --git a/docs/editing-existing-text.md b/docs/editing-existing-text.md index b7716cf4d9..6127f9a234 100644 --- a/docs/editing-existing-text.md +++ b/docs/editing-existing-text.md @@ -86,7 +86,8 @@ selects for Thai, not just a GPOS table); GSUB substitution is optional, as spec [the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai). Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS; their alternates are used only when each kept one has a mark's zero advance (or, for the two -consonant forms, an advance). A THSarabunPSK program with its own Thai mark +consonant forms, an advance) and, where the PDF's own map names its character, the character +that alternate stands for. A THSarabunPSK program with its own Thai mark positioning, such as a complete face loaded with Load font, is shaped by that layout instead, still in its own codes. One without it takes the legacy path only when every Thai glyph it keeps is where the legacy glyph order puts it, and is otherwise shaped as an ordinary source font. A