mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
M7: read each grouped form once and check PUA alternate meanings
Change: - Leave a form found to be artwork, or refused for a reason in the form itself, out of later preparation passes, so a page with many logos or unsupported groups no longer reaches the 64-placement limit and prepares nothing. ungroup_ops now marks each refusal as intrinsic or contextual; contextual ones (translucency, the resources it would merge into, pattern placement, the decode budget) are re-evaluated where the form appears, so a form refused in one place is still prepared where a parent draws it. - Refuse a page-level Do with an operand too many for that placement only: the form found by its first operand may not be the one drawn. Check a form's own operand counts before the nested walk, as an intrinsic refusal. - Use the Windows PUA alternates only when every kept alternate whose code the PDF's map names a Thai character names that alternate's base character, so alternates in another order that the map reveals are refused. Chores: - Say that the font program confirms each recovered glyph's advance class, not its identity, and document the alternate check. Validation: with and without craft-fonts, fonts 105 (+3 build-input), edit 468, engine 482 (+28 integration) and automation 50 + 352 passed; editor flows 248 passed. Formatting, strict Clippy, layers, assets and parity passed; the wasm check was not run locally. Each new regression failed with its fix reverted. Real-PDF edits on Royal Gazette 26288 kept their source fonts.
This commit is contained in:
1 parent
7634ac8064
commit
a453112151
5 files changed
+176
-37
No files matched your search
@@ -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::<Vec<_>>());
|
||||
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() {
|
||||
|
||||
@@ -32,6 +32,11 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
let mut bytes_left = 16usize << 20;
|
||||
let mut visits = 0usize;
|
||||
let mut decode = pdfcraft_cos::DecodeBudget::new(16 << 20, 0);
|
||||
// Forms found to be artwork, or refused for a reason in the form itself (`Refusal::intrinsic`):
|
||||
// a later pass leaves their placements as they are without reading or counting them again.
|
||||
// Only placements that are read count against the limit, so a page of logos doesn't use it
|
||||
// up pass after pass. A form refused only where it is drawn is read again where it appears.
|
||||
let mut settled = std::collections::HashSet::new();
|
||||
// One pass opens one level of every placement; the last pass only reports what is deeper.
|
||||
for pass in 0..=MAX_LEVELS {
|
||||
let p = page_dict(&next, page)?;
|
||||
@@ -50,6 +55,7 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
let mut merged = resources;
|
||||
let mut pending_path = false;
|
||||
let mut in_text = false;
|
||||
let (mut refused_here, mut expanded_here) = (std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => pending_path = true,
|
||||
@@ -59,23 +65,33 @@ pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError
|
||||
_ => {}
|
||||
}
|
||||
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<Preparation, EditError
|
||||
merged = res;
|
||||
replacements.insert(at, inline);
|
||||
result.expanded += 1;
|
||||
expanded_here.extend(image.object);
|
||||
}
|
||||
Err(refusal) => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<Ungrouped, EditError> {
|
||||
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
|
||||
) -> Result<Ungrouped, Refusal> {
|
||||
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<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 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()));
|
||||
|
||||
@@ -120,7 +120,8 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
];
|
||||
// The anchors identify the exporter; the font program confirms each glyph. Every glyph the
|
||||
// The anchors identify the exporter; the font program confirms each glyph's advance class
|
||||
// (not its identity, which nothing in such a subset records). Every glyph the
|
||||
// subset kept must advance as the character it is given (a vowel or tone mark set on its
|
||||
// base takes no advance, every other character does), else the order differs somewhere and
|
||||
// nothing is mapped. A glyph the subset dropped is left unmapped: no code can draw it, and
|
||||
@@ -143,6 +144,11 @@ pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The character each legacy Windows Thai presentation form U+F700 + index stands for: ฐ and ญ
|
||||
/// without descenders, and the vowels and tone marks moved left, lower or both (the PUA
|
||||
/// assignments of <https://linux.thai.net/~thep/th-otf/shaping.html>).
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user