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:
xullul committed 2026-11-07 17:00:48 +07:00
1 parent 7634ac8064
commit a453112151
5 files changed
+176 -37

No files matched your search

+74
View File
@@ -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() {
+29 -6
View File
@@ -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;
}
+41 -26
View File
@@ -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()));
+30 -4
View File
@@ -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]
+2 -1
View File
@@ -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