use crate::error::{RedactError, Result};
const MAX_BFRANGE_SPAN: u32 = 65_536;
const MAX_BFCHAR_PER_BLOCK: usize = 100;
const MAX_TOTAL_EXTRACTED_CODES: usize = 1_048_576;
pub(crate) const STUB_MARKER_KEY: &[u8] = b"XfaRedactionStub";
#[derive(Debug, Clone)]
pub(crate) struct TounicodeCodes {
pub(crate) codespace_ranges: Vec<(Vec<u8>, Vec<u8>)>,
pub(crate) codes: Vec<Vec<u8>>,
}
pub(crate) fn extract_tounicode_codes(
bytes: &[u8],
font_resource_name: &str,
) -> Result<TounicodeCodes> {
let mut scanner = Scanner::new(bytes);
let mut codespace_ranges: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
let mut codes: Vec<Vec<u8>> = Vec::new();
while let Some(tok) = scanner.next_token() {
match tok.as_slice() {
b"begincodespacerange" => {
parse_codespace_block(&mut scanner, &mut codespace_ranges, font_resource_name)?
}
b"beginbfchar" => {
parse_bfchar_block(&mut scanner, &mut codes, font_resource_name)?;
}
b"beginbfrange" => {
parse_bfrange_block(&mut scanner, &mut codes, font_resource_name)?;
}
_ => {}
}
}
if codespace_ranges.is_empty() {
return Err(unsupported(
font_resource_name,
"no codespacerange declared",
));
}
if codes.is_empty() {
return Err(unsupported(
font_resource_name,
"no bfchar/bfrange entries extracted",
));
}
let codespace_lens: Vec<usize> = codespace_ranges.iter().map(|(lo, _)| lo.len()).collect();
for code in &codes {
if !codespace_lens.contains(&code.len()) {
return Err(unsupported(
font_resource_name,
"code byte-length does not match any codespacerange",
));
}
}
Ok(TounicodeCodes {
codespace_ranges,
codes,
})
}
pub(crate) fn build_stub_cmap(codes: &TounicodeCodes) -> Vec<u8> {
let mut out: Vec<u8> = Vec::with_capacity(512 + codes.codes.len() * 16);
out.extend_from_slice(
b"/CIDInit /ProcSet findresource begin\n\
12 dict begin\n\
begincmap\n\
/CIDSystemInfo\n\
<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n\
/CMapType 2 def\n",
);
out.extend_from_slice(
format!("{} begincodespacerange\n", codes.codespace_ranges.len()).as_bytes(),
);
for (lo, hi) in &codes.codespace_ranges {
out.push(b'<');
for byte in lo {
out.extend_from_slice(format!("{byte:02X}").as_bytes());
}
out.extend_from_slice(b"> <");
for byte in hi {
out.extend_from_slice(format!("{byte:02X}").as_bytes());
}
out.extend_from_slice(b">\n");
}
out.extend_from_slice(b"endcodespacerange\n");
for chunk in codes.codes.chunks(MAX_BFCHAR_PER_BLOCK) {
out.extend_from_slice(format!("{} beginbfchar\n", chunk.len()).as_bytes());
for code in chunk {
out.push(b'<');
for byte in code {
out.extend_from_slice(format!("{byte:02X}").as_bytes());
}
out.extend_from_slice(b"> <FFFD>\n");
}
out.extend_from_slice(b"endbfchar\n");
}
out.extend_from_slice(
b"endcmap\n\
CMapName currentdict /CMap defineresource pop\n\
end\n\
end\n",
);
out
}
pub(crate) fn is_safe_stub_cmap(bytes: &[u8]) -> bool {
let mut scanner = Scanner::new(bytes);
let mut had_any_bfchar = false;
while let Some(tok) = scanner.next_token() {
match tok.as_slice() {
b"beginbfchar" => loop {
let next = match scanner.next_token() {
Some(t) => t,
None => return false,
};
if next == b"endbfchar" {
break;
}
let dst = match scanner.next_token() {
Some(t) => t,
None => return false,
};
if !is_replacement_char_destination(&dst) {
return false;
}
had_any_bfchar = true;
},
b"beginbfrange" => {
return false;
}
b"usecmap" => {
return false;
}
_ => {}
}
}
had_any_bfchar
}
fn is_replacement_char_destination(token: &[u8]) -> bool {
if token.len() < 2 || token[0] != b'<' || token[token.len() - 1] != b'>' {
return false;
}
let inner: Vec<u8> = token[1..token.len() - 1]
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
inner.eq_ignore_ascii_case(b"FFFD")
}
struct Scanner<'a> {
bytes: &'a [u8],
pos: usize,
}
impl<'a> Scanner<'a> {
fn new(bytes: &'a [u8]) -> Self {
Self { bytes, pos: 0 }
}
fn skip_whitespace_and_comments(&mut self) {
while self.pos < self.bytes.len() {
let c = self.bytes[self.pos];
if c == b'%' {
while self.pos < self.bytes.len() {
let cc = self.bytes[self.pos];
if cc == b'\n' || cc == b'\r' {
break;
}
self.pos += 1;
}
} else if c.is_ascii_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn next_token(&mut self) -> Option<Vec<u8>> {
self.skip_whitespace_and_comments();
if self.pos >= self.bytes.len() {
return None;
}
let start = self.pos;
let c = self.bytes[self.pos];
if c == b'<' {
self.pos += 1;
while self.pos < self.bytes.len() && self.bytes[self.pos] != b'>' {
self.pos += 1;
}
if self.pos < self.bytes.len() {
self.pos += 1; }
return Some(self.bytes[start..self.pos].to_vec());
}
if c == b'[' {
let mut depth = 1usize;
self.pos += 1;
while self.pos < self.bytes.len() && depth > 0 {
match self.bytes[self.pos] {
b'[' => depth += 1,
b']' => depth -= 1,
_ => {}
}
self.pos += 1;
}
return Some(self.bytes[start..self.pos].to_vec());
}
while self.pos < self.bytes.len() {
let cc = self.bytes[self.pos];
if cc.is_ascii_whitespace() || cc == b'<' || cc == b'[' || cc == b'%' {
break;
}
self.pos += 1;
}
Some(self.bytes[start..self.pos].to_vec())
}
}
fn parse_hex_string(token: &[u8], font: &str) -> Result<Vec<u8>> {
if token.len() < 2 || token[0] != b'<' || token[token.len() - 1] != b'>' {
return Err(unsupported(font, "expected hex string"));
}
let inner = &token[1..token.len() - 1];
let mut stripped: Vec<u8> = inner
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
if !stripped.len().is_multiple_of(2) {
stripped.push(b'0');
}
let mut out = Vec::with_capacity(stripped.len() / 2);
for pair in stripped.chunks_exact(2) {
let hi = hex_digit(pair[0]).ok_or_else(|| unsupported(font, "non-hex char"))?;
let lo = hex_digit(pair[1]).ok_or_else(|| unsupported(font, "non-hex char"))?;
out.push((hi << 4) | lo);
}
Ok(out)
}
fn hex_digit(c: u8) -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(10 + (c - b'a')),
b'A'..=b'F' => Some(10 + (c - b'A')),
_ => None,
}
}
fn parse_codespace_block(
scanner: &mut Scanner,
out: &mut Vec<(Vec<u8>, Vec<u8>)>,
font: &str,
) -> Result<()> {
loop {
let tok = scanner
.next_token()
.ok_or_else(|| unsupported(font, "unterminated codespacerange block"))?;
if tok == b"endcodespacerange" {
return Ok(());
}
let lo = parse_hex_string(&tok, font)?;
let hi_tok = scanner
.next_token()
.ok_or_else(|| unsupported(font, "missing codespace hi value"))?;
let hi = parse_hex_string(&hi_tok, font)?;
if lo.is_empty() || lo.len() != hi.len() || lo.len() > 4 {
return Err(unsupported(
font,
"codespace range with mismatched or out-of-bounds byte length",
));
}
out.push((lo, hi));
}
}
fn try_push_code(out: &mut Vec<Vec<u8>>, code: Vec<u8>, font: &str) -> Result<()> {
if out.len() >= MAX_TOTAL_EXTRACTED_CODES {
return Err(unsupported(
font,
"cumulative extracted-code count exceeds safety cap \
(refused as malformed/adversarial)",
));
}
out.push(code);
Ok(())
}
fn parse_bfchar_block(scanner: &mut Scanner, out: &mut Vec<Vec<u8>>, font: &str) -> Result<()> {
loop {
let tok = scanner
.next_token()
.ok_or_else(|| unsupported(font, "unterminated bfchar block"))?;
if tok == b"endbfchar" {
return Ok(());
}
let src = parse_hex_string(&tok, font)?;
let _dst = scanner
.next_token()
.ok_or_else(|| unsupported(font, "bfchar entry missing destination"))?;
if src.is_empty() || src.len() > 4 {
return Err(unsupported(
font,
"bfchar source has unsupported byte length",
));
}
try_push_code(out, src, font)?;
}
}
fn parse_bfrange_block(scanner: &mut Scanner, out: &mut Vec<Vec<u8>>, font: &str) -> Result<()> {
loop {
let tok = scanner
.next_token()
.ok_or_else(|| unsupported(font, "unterminated bfrange block"))?;
if tok == b"endbfrange" {
return Ok(());
}
let src_lo = parse_hex_string(&tok, font)?;
let src_hi_tok = scanner
.next_token()
.ok_or_else(|| unsupported(font, "bfrange entry missing srcHi"))?;
let src_hi = parse_hex_string(&src_hi_tok, font)?;
let _dst = scanner
.next_token()
.ok_or_else(|| unsupported(font, "bfrange entry missing destination"))?;
if src_lo.len() != src_hi.len() || src_lo.is_empty() || src_lo.len() > 4 {
return Err(unsupported(
font,
"bfrange src bounds with mismatched or out-of-bounds byte length",
));
}
let lo_int = bytes_to_u32(&src_lo);
let hi_int = bytes_to_u32(&src_hi);
if hi_int < lo_int {
return Err(unsupported(font, "bfrange srcHi < srcLo"));
}
let span = hi_int.saturating_sub(lo_int).saturating_add(1);
if span > MAX_BFRANGE_SPAN {
return Err(unsupported(
font,
"bfrange span exceeds safety cap (refused as malformed/adversarial)",
));
}
let byte_len = src_lo.len();
for code_int in lo_int..=hi_int {
try_push_code(out, u32_to_bytes(code_int, byte_len), font)?;
}
}
}
fn bytes_to_u32(bytes: &[u8]) -> u32 {
let mut v = 0u32;
for &b in bytes {
v = (v << 8) | b as u32;
}
v
}
fn u32_to_bytes(v: u32, byte_len: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(byte_len);
for i in (0..byte_len).rev() {
out.push(((v >> (i * 8)) & 0xFF) as u8);
}
out
}
fn unsupported(font: &str, reason: &str) -> RedactError {
RedactError::UnsupportedToUnicodeCMap {
font_resource_name: font.to_string(),
reason: reason.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmap_with_bfchar() -> &'static [u8] {
b"\
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo
<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
1 begincodespacerange
<00> <FF>
endcodespacerange
3 beginbfchar
<41> <0041>
<42> <0042>
<43> <0043>
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
"
}
fn cmap_with_bfrange_single_dest() -> &'static [u8] {
b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
1 beginbfrange
<0010> <0014> <0050>
endbfrange
endcmap
"
}
fn cmap_with_bfrange_array_dest() -> &'static [u8] {
b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
1 beginbfrange
<00A0> <00A2> [<0061> <0062> <0063>]
endbfrange
endcmap
"
}
fn cmap_empty() -> &'static [u8] {
b"\
/CIDInit /ProcSet findresource begin
begincmap
endcmap
"
}
#[test]
fn extract_codes_from_bfchar_block() {
let codes = extract_tounicode_codes(cmap_with_bfchar(), "F1").unwrap();
assert_eq!(codes.codespace_ranges.len(), 1);
assert_eq!(codes.codespace_ranges[0].0, vec![0x00]);
assert_eq!(codes.codespace_ranges[0].1, vec![0xFF]);
assert_eq!(codes.codes, vec![vec![0x41], vec![0x42], vec![0x43]]);
}
#[test]
fn extract_codes_from_bfrange_single_dest_enumerates_source() {
let codes = extract_tounicode_codes(cmap_with_bfrange_single_dest(), "F2").unwrap();
assert_eq!(codes.codes.len(), 5);
assert_eq!(codes.codes[0], vec![0x00, 0x10]);
assert_eq!(codes.codes[4], vec![0x00, 0x14]);
}
#[test]
fn extract_codes_from_bfrange_array_dest_enumerates_source() {
let codes = extract_tounicode_codes(cmap_with_bfrange_array_dest(), "F3").unwrap();
assert_eq!(codes.codes.len(), 3);
assert_eq!(codes.codes[0], vec![0x00, 0xA0]);
assert_eq!(codes.codes[2], vec![0x00, 0xA2]);
}
#[test]
fn extract_codes_returns_err_on_empty_cmap() {
let err = extract_tounicode_codes(cmap_empty(), "F4").unwrap_err();
assert!(matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }));
}
#[test]
fn extract_codes_caps_huge_bfrange_span() {
let big = b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<00000000> <FFFFFFFF>
endcodespacerange
1 beginbfrange
<00000000> <FFFFFFFF> <FFFD>
endbfrange
endcmap
";
let err = extract_tounicode_codes(big, "F5").unwrap_err();
assert!(matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }));
}
#[test]
fn build_stub_cmap_emits_codespace_and_bfchar_only() {
let codes = TounicodeCodes {
codespace_ranges: vec![(vec![0x00], vec![0xFF])],
codes: vec![vec![0x41], vec![0x42], vec![0xFE]],
};
let bytes = build_stub_cmap(&codes);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("1 begincodespacerange"));
assert!(s.contains("<00> <FF>"));
assert!(s.contains("3 beginbfchar"));
assert!(s.contains("<41> <FFFD>"));
assert!(s.contains("<42> <FFFD>"));
assert!(s.contains("<FE> <FFFD>"));
assert!(!s.contains("beginbfrange"));
}
#[test]
fn build_stub_cmap_splits_into_blocks_of_100() {
let codes = TounicodeCodes {
codespace_ranges: vec![(vec![0x00], vec![0xFF])],
codes: (0u8..=199).map(|b| vec![b]).collect(),
};
let bytes = build_stub_cmap(&codes);
let s = std::str::from_utf8(&bytes).unwrap();
let block_count = s.matches("beginbfchar").count();
assert_eq!(block_count, 2, "expected 2 bfchar blocks for 200 codes");
assert!(s.contains("100 beginbfchar"));
}
#[test]
fn extract_codes_handles_cr_only_line_endings_in_comments() {
let cmap_cr_only: Vec<u8> = b"\
% comment line that ends with carriage return only
1 begincodespacerange
<00> <FF>
endcodespacerange
2 beginbfchar
<41> <0041>
<42> <0042>
endbfchar
endcmap
"
.iter()
.map(|&b| if b == b'\n' { b'\r' } else { b })
.collect();
let codes = extract_tounicode_codes(&cmap_cr_only, "F1")
.expect("CR-only-line CMap with leading comment must parse");
assert_eq!(codes.codes, vec![vec![0x41], vec![0x42]]);
}
#[test]
fn extract_codes_handles_crlf_line_endings() {
let cmap_crlf: Vec<u8> = b"\
% comment with CRLF terminator
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfchar
<5A> <005A>
endbfchar
endcmap
"
.iter()
.flat_map(|&b| {
if b == b'\n' {
vec![b'\r', b'\n']
} else {
vec![b]
}
})
.collect();
let codes = extract_tounicode_codes(&cmap_crlf, "F2")
.expect("CRLF-terminated CMap with leading comment must parse");
assert_eq!(codes.codes, vec![vec![0x5A]]);
}
#[test]
fn extract_codes_pads_odd_length_hex_source_codes() {
let cmap = b"\
1 begincodespacerange
<00> <FF>
endcodespacerange
2 beginbfchar
<4> <0041>
<5> <0042>
endbfchar
endcmap
";
let codes = extract_tounicode_codes(cmap, "F3")
.expect("CMap with odd-length hex source codes must parse with `0` padding");
assert_eq!(codes.codes, vec![vec![0x40], vec![0x50]]);
}
#[test]
fn parse_hex_string_pads_odd_length_directly() {
let parsed = parse_hex_string(b"<F>", "F4").unwrap();
assert_eq!(parsed, vec![0xF0]);
let parsed = parse_hex_string(b"<ABC>", "F5").unwrap();
assert_eq!(parsed, vec![0xAB, 0xC0]);
let parsed = parse_hex_string(b"<ABCD>", "F6").unwrap();
assert_eq!(parsed, vec![0xAB, 0xCD]);
}
#[test]
fn safe_stub_validator_accepts_our_own_stub_output() {
let codes = TounicodeCodes {
codespace_ranges: vec![(vec![0x00], vec![0xFF])],
codes: vec![vec![0x41], vec![0x42], vec![0x43], vec![0xFE]],
};
let stub = build_stub_cmap(&codes);
assert!(
is_safe_stub_cmap(&stub),
"build_stub_cmap output must validate as safe stub"
);
}
#[test]
fn safe_stub_validator_rejects_original_tounicode_with_real_mappings() {
let original = b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<00> <FF>
endcodespacerange
3 beginbfchar
<41> <0041>
<42> <0042>
<43> <0043>
endbfchar
endcmap
";
assert!(
!is_safe_stub_cmap(original),
"real-mapping CMap must NOT validate as safe stub"
);
}
#[test]
fn safe_stub_validator_rejects_partial_replacement_with_one_real_mapping() {
let crafted = b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<00> <FF>
endcodespacerange
3 beginbfchar
<41> <FFFD>
<42> <FFFD>
<43> <0043>
endbfchar
endcmap
";
assert!(
!is_safe_stub_cmap(crafted),
"any single non-U+FFFD destination disqualifies the stream as safe stub"
);
}
#[test]
fn safe_stub_validator_rejects_bfrange_block_even_when_dest_is_replacement_char() {
let stream_with_bfrange = b"\
/CIDInit /ProcSet findresource begin
begincmap
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfrange
<00> <FF> <FFFD>
endbfrange
endcmap
";
assert!(
!is_safe_stub_cmap(stream_with_bfrange),
"bfrange presence must disqualify the stream as safe stub (even with U+FFFD dest)"
);
}
#[test]
fn safe_stub_validator_rejects_empty_stream() {
assert!(!is_safe_stub_cmap(b""));
assert!(!is_safe_stub_cmap(b"begincmap\nendcmap\n"));
}
#[test]
fn safe_stub_validator_rejects_usecmap_inheritance() {
let crafted = b"\
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CIDSystemInfo
<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def
/CMapName /Adobe-Identity-UCS def
/CMapType 2 def
/Identity-H usecmap
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfchar
<00> <FFFD>
endbfchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end
";
assert!(
!is_safe_stub_cmap(crafted),
"stream carrying `usecmap` must not be classified as safe stub even with single FFFD bfchar entry"
);
}
#[test]
fn safe_stub_validator_still_accepts_streams_with_findresource_and_defineresource() {
let codes = TounicodeCodes {
codespace_ranges: vec![(vec![0x00], vec![0xFF])],
codes: vec![vec![0x41]],
};
let stub = build_stub_cmap(&codes);
assert!(std::str::from_utf8(&stub).unwrap().contains("findresource"));
assert!(std::str::from_utf8(&stub)
.unwrap()
.contains("defineresource"));
assert!(
is_safe_stub_cmap(&stub),
"standard CMap preamble (findresource/defineresource) must not trigger usecmap rejection"
);
}
#[test]
fn extract_codes_caps_cumulative_total_across_many_legal_ranges() {
let mut cmap: Vec<u8> = Vec::new();
cmap.extend_from_slice(
b"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<00000000> <FFFFFFFF>
endcodespacerange
",
);
for i in 0u32..17 {
let lo = i * 65_536;
let hi = lo + 65_535;
cmap.extend_from_slice(
format!(
"1 beginbfrange\n<{lo:08X}> <{hi:08X}> <FFFD>\nendbfrange\n",
lo = lo,
hi = hi
)
.as_bytes(),
);
}
cmap.extend_from_slice(b"endcmap\n");
let err = extract_tounicode_codes(&cmap, "F1").unwrap_err();
assert!(
matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }),
"cumulative-cap overflow must surface UnsupportedToUnicodeCMap, got {err:?}"
);
}
#[test]
fn extract_codes_caps_cumulative_total_across_many_bfchar_blocks() {
let mut cmap: Vec<u8> = Vec::new();
cmap.extend_from_slice(
b"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
",
);
for block in 0u32..11_000 {
cmap.extend_from_slice(b"100 beginbfchar\n");
for entry in 0u32..100 {
let code = block * 100 + entry;
cmap.extend_from_slice(
format!("<{code:04X}> <0041>\n", code = code & 0xFFFF).as_bytes(),
);
}
cmap.extend_from_slice(b"endbfchar\n");
}
cmap.extend_from_slice(b"endcmap\n");
let err = extract_tounicode_codes(&cmap, "F2").unwrap_err();
assert!(
matches!(err, RedactError::UnsupportedToUnicodeCMap { .. }),
"many-bfchar-block cumulative overflow must surface UnsupportedToUnicodeCMap"
);
}
#[test]
fn extract_then_build_stub_parses_via_pdf_font_and_returns_replacement_char() {
let original = b"\
/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
2 beginbfchar
<0010> <0048>
<0011> <0065>
endbfchar
2 beginbfrange
<0020> <0023> <006C>
<0030> <0032> [<006F> <0070> <0071>]
endbfrange
endcmap
";
let codes = extract_tounicode_codes(original, "F1").unwrap();
assert_eq!(codes.codes.len(), 9);
let stub_bytes = build_stub_cmap(&codes);
let cmap = pdf_font::cmap::CMap::parse(
&stub_bytes,
|_: pdf_font::cmap::CMapName<'_>| -> Option<&'static [u8]> { None },
)
.expect("build_stub_cmap output must parse via pdf_font::cmap::CMap");
for code in &codes.codes {
let mut code_int = 0u32;
for &b in code {
code_int = (code_int << 8) | b as u32;
}
let bf = cmap
.lookup_bf_string(code_int)
.unwrap_or_else(|| panic!("stub CMap missed lookup for code 0x{code_int:04X}"));
let chars: Vec<char> = match bf {
pdf_font::cmap::BfString::Char(c) => vec![c],
pdf_font::cmap::BfString::String(s) => s.chars().collect(),
};
assert!(
chars.iter().all(|c| *c == '\u{FFFD}'),
"code 0x{code_int:04X} resolved to {chars:?}, expected U+FFFD"
);
}
}
}