pdftract/crates/pdftract-core/src/font/codespace.rs
jedarden d88f52b806 test(pdftract-3g6ne): add Identity-H/V round-trip tests
Adds test_identity_h_roundtrip and test_identity_v_roundtrip tests
to fully satisfy the final acceptance criterion for round-trip with
Identity-H CMap fixture.

Tests verify:
- Single 2-byte codespace range covering all 16-bit codes
- Correct parsing of <0000> <FFFF> range
- find_range() correctly identifies codes within the range

Related: pdftract-3g6ne
2026-05-28 07:21:49 -04:00

778 lines
24 KiB
Rust

//! CMap codespace range parser.
//!
//! This module implements parsing of the `begincodespacerange` / `endcodespacerange`
//! PostScript blocks in CMap streams. Codespace ranges define the legal byte-width
//! boundaries for character codes in a CMap.
//!
//! # Codespace ranges
//!
//! A codespace range defines a contiguous range of character codes with the same
//! byte width. For example:
//! - `<00> <7F>` → 1-byte range covering 0x00..=0x7F
//! - `<8000> <FFFF>` → 2-byte range
//! - `<8140> <FEFE>` → JIS lead/trail 2-byte pattern
//!
//! # PostScript syntax
//!
//! ```text
//! N begincodespacerange
//! <lo1> <hi1>
//! <lo2> <hi2>
//! ...
//! endcodespacerange
//! ```
//!
//! Each entry is two hex strings of equal byte width (1, 2, 3, or 4 bytes after hex decode).
use smallvec::SmallVec;
use crate::diagnostics::{DiagCode, Diagnostic};
/// A single codespace range.
///
/// Defines a contiguous range of character codes with a fixed byte width.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodespaceRange {
/// Low bound of the range (inclusive), stored big-endian in 4 bytes
pub lo: [u8; 4],
/// High bound of the range (inclusive), stored big-endian in 4 bytes
pub hi: [u8; 4],
/// Byte width of this range (1, 2, 3, or 4)
pub width: u8,
}
impl CodespaceRange {
/// Create a new codespace range.
///
/// # Arguments
///
/// * `lo` - Low bound bytes (big-endian)
/// * `hi` - High bound bytes (big-endian)
///
/// # Returns
///
/// `None` if lo and hi have different lengths or if width is not 1-4.
pub fn new(lo: Vec<u8>, hi: Vec<u8>) -> Option<Self> {
if lo.len() != hi.len() {
return None;
}
let width = lo.len();
if !(1..=4).contains(&width) {
return None;
}
// Convert to 4-byte big-endian arrays
let mut lo_arr = [0u8; 4];
let mut hi_arr = [0u8; 4];
let offset = 4 - width;
lo_arr[offset..].copy_from_slice(&lo);
hi_arr[offset..].copy_from_slice(&hi);
Some(CodespaceRange {
lo: lo_arr,
hi: hi_arr,
width: width as u8,
})
}
/// Get the low bound as a slice (without leading zeros).
pub fn lo_slice(&self) -> &[u8] {
let offset = 4 - self.width as usize;
&self.lo[offset..]
}
/// Get the high bound as a slice (without leading zeros).
pub fn hi_slice(&self) -> &[u8] {
let offset = 4 - self.width as usize;
&self.hi[offset..]
}
}
/// Collection of codespace ranges from a CMap.
///
/// Most predefined CMaps (Identity-H/V, UTF-16 variants) use 1-byte ASCII
/// plus 2-byte CJK ranges.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CodespaceRanges {
/// The ranges in this collection (typically 1-8 entries)
pub ranges: SmallVec<[CodespaceRange; 8]>,
}
impl CodespaceRanges {
/// Create a new empty collection.
pub fn new() -> Self {
Self {
ranges: SmallVec::new(),
}
}
/// Add a range to the collection.
pub fn add(&mut self, range: CodespaceRange) {
self.ranges.push(range);
}
/// Check if the collection is empty.
pub fn is_empty(&self) -> bool {
self.ranges.is_empty()
}
/// Get the number of ranges.
pub fn len(&self) -> usize {
self.ranges.len()
}
/// Find the matching range for a given byte sequence.
///
/// Returns the range if the byte sequence falls within it, considering width.
pub fn find_range(&self, code: &[u8]) -> Option<&CodespaceRange> {
for range in &self.ranges {
let width = range.width as usize;
if code.len() == width {
let offset = 4 - width;
let lo = &range.lo[offset..];
let hi = &range.hi[offset..];
if code >= lo && code <= hi {
return Some(range);
}
}
}
None
}
}
/// Error that can occur during codespace range parsing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodespaceError {
/// Unexpected token in CMap stream.
UnexpectedToken(String),
/// Invalid hex string format.
InvalidHexString(String),
/// Missing expected keyword (e.g., endcodespacerange).
MissingKeyword(String),
/// Width mismatch between lo and hi bounds.
WidthMismatch,
}
impl std::fmt::Display for CodespaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodespaceError::UnexpectedToken(msg) => write!(f, "unexpected token: {}", msg),
CodespaceError::InvalidHexString(msg) => write!(f, "invalid hex string: {}", msg),
CodespaceError::MissingKeyword(kw) => write!(f, "missing expected keyword: {}", kw),
CodespaceError::WidthMismatch => write!(f, "codespace range lo/hi width mismatch"),
}
}
}
/// Codespace range parser.
///
/// Parses a PostScript CMap program and extracts codespace ranges.
pub struct CodespaceParser<'a> {
/// Input bytes
input: &'a [u8],
/// Current position
pos: usize,
/// Accumulated diagnostics
diagnostics: Vec<Diagnostic>,
}
impl<'a> CodespaceParser<'a> {
/// Create a new codespace parser for the given input bytes.
pub fn new(input: &'a [u8]) -> Self {
Self {
input,
pos: 0,
diagnostics: Vec::new(),
}
}
/// Parse the codespace ranges from the input.
///
/// Returns the parsed ranges and any diagnostics generated during parsing.
pub fn parse(mut self) -> (CodespaceRanges, Vec<Diagnostic>) {
let mut ranges = CodespaceRanges::new();
while self.pos < self.input.len() {
// Skip whitespace and comments
self.skip_whitespace_and_comments();
// Check for EOF
if self.pos >= self.input.len() {
break;
}
// Try to parse begincodespacerange
if self.try_keyword(b"begincodespacerange") {
if let Err(e) = self.parse_codespace_block(&mut ranges) {
self.emit_error(&e);
// Attempt recovery: skip to endcodespacerange
self.skip_to_keyword(b"endcodespacerange");
}
continue;
}
// Skip unknown tokens
self.skip_token();
}
(ranges, self.diagnostics)
}
/// Parse a begincodespacerange...endcodespacerange block.
fn parse_codespace_block(&mut self, ranges: &mut CodespaceRanges) -> Result<(), CodespaceError> {
// Read count (optional in some CMaps, but standard requires it)
let count = if let Ok(n) = self.try_integer() {
if n < 0 {
return Err(CodespaceError::UnexpectedToken(
"negative range count".to_string(),
));
}
n as usize
} else {
// No count - parse until endcodespacerange
usize::MAX
};
let mut parsed = 0;
while parsed < count {
self.skip_whitespace_and_comments();
// Check for endcodespacerange
if self.try_keyword(b"endcodespacerange") {
break;
}
// Parse lo hex string
let lo = self.expect_hex_string()?;
// Skip whitespace
self.skip_whitespace();
// Parse hi hex string
let hi = self.expect_hex_string()?;
// Create range
if let Some(range) = CodespaceRange::new(lo, hi) {
ranges.add(range);
parsed += 1;
} else {
// Width mismatch or invalid width
self.diagnostics.push(Diagnostic::with_dynamic(
DiagCode::FontInvalidCmap,
self.pos as u64,
format!("codespace range lo/hi width mismatch or invalid width"),
));
// Continue parsing other ranges
parsed += 1;
}
self.skip_whitespace_and_comments();
}
// If we had a count, expect endcodespacerange
if count != usize::MAX && !self.try_keyword(b"endcodespacerange") {
return Err(CodespaceError::MissingKeyword("endcodespacerace".to_string()));
}
Ok(())
}
/// Try to read an integer at the current position.
fn try_integer(&mut self) -> Result<i64, CodespaceError> {
self.skip_whitespace_and_comments();
let start = self.pos;
// Optional sign
if self.pos < self.input.len() && (self.input[self.pos] == b'-' || self.input[self.pos] == b'+') {
self.pos += 1;
}
// Digits
while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() {
self.pos += 1;
}
if self.pos == start {
return Err(CodespaceError::UnexpectedToken(
"expected integer".to_string(),
));
}
// Parse the integer
let s = unsafe { std::str::from_utf8_unchecked(&self.input[start..self.pos]) };
s.parse().map_err(|_| CodespaceError::UnexpectedToken("invalid integer".to_string()))
}
/// Expect a hex string at the current position.
fn expect_hex_string(&mut self) -> Result<Vec<u8>, CodespaceError> {
self.skip_whitespace_and_comments();
if self.pos >= self.input.len() {
return Err(CodespaceError::MissingKeyword("<hex string>".to_string()));
}
if self.input[self.pos] != b'<' {
return Err(CodespaceError::UnexpectedToken("expected <".to_string()));
}
self.pos += 1;
let mut result = Vec::new();
let mut current_nibble: Option<u8> = None;
while self.pos < self.input.len() {
let b = self.input[self.pos];
self.pos += 1;
if b == b'>' {
// End of hex string
if let Some(hi) = current_nibble {
result.push(hi << 4);
}
return Ok(result);
}
// Try to parse hex digit
if let Some(nibble) = Self::hex_digit_to_nibble(b) {
if let Some(hi) = current_nibble {
result.push(hi << 4 | nibble);
current_nibble = None;
} else {
current_nibble = Some(nibble);
}
} else if Self::is_whitespace(b) {
// Whitespace is ignored
continue;
} else {
return Err(CodespaceError::InvalidHexString(format!(
"invalid hex character: 0x{:02x}",
b
)));
}
}
// EOF before >
if let Some(hi) = current_nibble {
result.push(hi << 4);
}
Ok(result)
}
/// Try to match a keyword at the current position.
fn try_keyword(&mut self, keyword: &[u8]) -> bool {
self.skip_whitespace_and_comments();
if self.input[self.pos..].starts_with(keyword) {
// Check that the keyword is followed by whitespace or delimiter
let next_pos = self.pos + keyword.len();
if next_pos < self.input.len() {
let next = self.input[next_pos];
if !Self::is_whitespace(next) && !Self::is_delimiter(next) {
return false;
}
}
self.pos += keyword.len();
return true;
}
false
}
/// Skip whitespace and comments.
fn skip_whitespace_and_comments(&mut self) {
while self.pos < self.input.len() {
let b = self.input[self.pos];
// Skip whitespace
if Self::is_whitespace(b) {
self.pos += 1;
continue;
}
// Skip comment
if b == b'%' {
self.pos += 1;
// Skip to end of line
while self.pos < self.input.len() && self.input[self.pos] != b'\n' {
self.pos += 1;
}
// Include the newline
if self.pos < self.input.len() {
self.pos += 1;
}
continue;
}
break;
}
}
/// Skip whitespace only (not comments).
fn skip_whitespace(&mut self) {
while self.pos < self.input.len() && Self::is_whitespace(self.input[self.pos]) {
self.pos += 1;
}
}
/// Skip a single token (until whitespace or delimiter).
fn skip_token(&mut self) {
self.skip_whitespace_and_comments();
while self.pos < self.input.len() {
let b = self.input[self.pos];
if Self::is_whitespace(b) || Self::is_delimiter(b) {
break;
}
self.pos += 1;
}
}
/// Skip tokens until we find the expected keyword.
fn skip_to_keyword(&mut self, keyword: &[u8]) {
while self.pos < self.input.len() {
self.skip_whitespace_and_comments();
if self.try_keyword(keyword) {
break;
}
self.skip_token();
}
}
/// Check if a byte is whitespace.
fn is_whitespace(b: u8) -> bool {
matches!(b, b' ' | b'\t' | b'\n' | b'\r' | b'\x0C')
}
/// Check if a byte is a delimiter.
fn is_delimiter(b: u8) -> bool {
matches!(b, b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%' | b'(' | b')')
}
/// Convert a hex digit character to its 4-bit value.
fn hex_digit_to_nibble(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
/// Emit an error as a diagnostic.
fn emit_error(&mut self, error: &CodespaceError) {
self.diagnostics.push(Diagnostic::with_dynamic(
DiagCode::FontInvalidCmap,
self.pos as u64,
error.to_string(),
));
}
}
/// Parse codespace ranges from raw bytes.
///
/// Convenience function that creates a parser and returns just the ranges.
pub fn parse_codespace_ranges(input: &[u8]) -> CodespaceRanges {
let parser = CodespaceParser::new(input);
let (ranges, _diagnostics) = parser.parse();
ranges
}
/// Parse codespace ranges from raw bytes with diagnostics.
///
/// Returns both the ranges and any diagnostics generated during parsing.
pub fn parse_codespace_ranges_with_diags(input: &[u8]) -> (CodespaceRanges, Vec<Diagnostic>) {
let parser = CodespaceParser::new(input);
parser.parse()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_range_one_byte() {
// Acceptance criterion: Parse <00> <7F> → 1 range, width=1
let input = b"1 begincodespacerange\n<00> <7F>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 1);
assert_eq!(ranges.ranges[0].lo_slice(), &[0x00]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0x7F]);
assert!(diags.is_empty());
}
#[test]
fn test_parse_two_ranges_mixed_width() {
// Acceptance criterion: Parse <00> <7F> <8000> <FFFF> in one block
let input = b"2 begincodespacerange\n<00> <7F>\n<8000> <FFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
assert_eq!(ranges.len(), 2);
assert_eq!(ranges.ranges[0].width, 1);
assert_eq!(ranges.ranges[0].lo_slice(), &[0x00]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0x7F]);
assert_eq!(ranges.ranges[1].width, 2);
assert_eq!(ranges.ranges[1].lo_slice(), &[0x80, 0x00]);
assert_eq!(ranges.ranges[1].hi_slice(), &[0xFF, 0xFF]);
assert!(diags.is_empty());
}
#[test]
fn test_width_inference() {
// Acceptance criterion: 2-char hex → width=1; 4-char hex → width=2
let input = b"2 begincodespacerange\n<AB> <CD>\n<1234> <5678>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 2);
assert_eq!(ranges.ranges[0].width, 1);
assert_eq!(ranges.ranges[1].width, 2);
}
#[test]
fn test_case_insensitive_hex() {
// Acceptance criterion: Case-insensitive hex (<C0> and <c0> equivalent)
let input = b"1 begincodespacerange\n<C0> <c0>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].lo_slice(), &[0xC0]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0xC0]);
}
#[test]
fn test_malformed_range_width_mismatch() {
// Acceptance criterion: Width-mismatch lo/hi → diagnostic + skipped
let input = b"1 begincodespacerange\n<00> <FFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
// Range should be skipped
assert_eq!(ranges.len(), 0);
// Should emit diagnostic
assert!(!diags.is_empty());
assert!(diags.iter().any(|d| d.code == DiagCode::FontInvalidCmap));
}
#[test]
fn test_empty_cmap() {
// Acceptance criterion: Empty CMap → empty ranges (defensive default applied elsewhere)
let input = b"";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert!(ranges.is_empty());
}
#[test]
fn test_no_codespace_block() {
// CMap with no begincodespacerange block
let input = b"/CMapName /Identity-H def\n10 beginbfchar\n1 endbfchar";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert!(ranges.is_empty());
}
#[test]
fn test_hex_string_with_whitespace() {
// Hex strings with internal whitespace should parse correctly
let input = b"1 begincodespacerange\n<00 01> <7F>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
// <00 01> with whitespace → 0x00 0x01 after parsing whitespace
// Actually whitespace is ignored, so <00 01> becomes <0001> = 2 bytes
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 2);
}
#[test]
fn test_jis_range() {
// JIS lead/trail 2-byte pattern
let input = b"1 begincodespacerange\n<8140> <FEFE>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 2);
assert_eq!(ranges.ranges[0].lo_slice(), &[0x81, 0x40]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0xFE, 0xFE]);
}
#[test]
fn test_three_byte_range() {
// 3-byte range
let input = b"1 begincodespacerange\n<800000> <FFFFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 3);
}
#[test]
fn test_four_byte_range() {
// 4-byte range
let input = b"1 begincodespacerange\n<80000000> <FFFFFFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 4);
}
#[test]
fn test_invalid_width_too_large() {
// 5-byte range is invalid
let input = b"1 begincodespacerange\n<0000000000> <FFFFFFFFFFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
assert_eq!(ranges.len(), 0);
assert!(!diags.is_empty());
}
#[test]
fn test_find_range() {
// Test finding a range for a given code
let input = b"2 begincodespacerange\n<00> <7F>\n<8000> <FFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
// Single-byte code should match first range
let range = ranges.find_range(&[0x41]);
assert!(range.is_some());
assert_eq!(range.unwrap().width, 1);
// Two-byte code should match second range
let range = ranges.find_range(&[0x81, 0x00]);
assert!(range.is_some());
assert_eq!(range.unwrap().width, 2);
// Code outside all ranges should not match
let range = ranges.find_range(&[0xFF, 0xFF, 0xFF]);
assert!(range.is_none());
}
#[test]
fn test_comment_in_block() {
// Comments should be ignored
let input = b"1 begincodespacerange\n% This is a comment\n<00> <7F>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
assert_eq!(ranges.len(), 1);
assert!(diags.is_empty());
}
#[test]
fn test_convenience_function() {
// Test the convenience function
let input = b"1 begincodespacerange\n<00> <7F>\nendcodespacerange";
let ranges = parse_codespace_ranges(input);
assert_eq!(ranges.len(), 1);
}
#[test]
fn test_convenience_function_with_diags() {
// Test the convenience function with diagnostics
let input = b"1 begincodespacerange\n<00> <FFFF>\nendcodespacerange";
let (ranges, diags) = parse_codespace_ranges_with_diags(input);
assert_eq!(ranges.len(), 0);
assert!(!diags.is_empty());
}
#[test]
fn test_odd_length_hex_string() {
// Odd-length hex string: <4> → 0x40 (dangling nibble padded)
let input = b"1 begincodespacerange\n<4> <A>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, _) = parser.parse();
assert_eq!(ranges.len(), 1);
// <4> becomes 0x40, <A> becomes 0xA0
assert_eq!(ranges.ranges[0].lo_slice(), &[0x40]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0xA0]);
}
#[test]
fn test_recovery_on_error() {
// Parse should continue after a malformed entry
let input = b"3 begincodespacerange\n<00> <7F>\n<00> <FFFF>\n<8000> <FFFF>\nendcodespacerange";
let parser = CodespaceParser::new(input);
let (ranges, diags) = parser.parse();
// First and third ranges should be parsed, second should be skipped
assert_eq!(ranges.len(), 2);
assert!(!diags.is_empty());
}
#[test]
fn test_identity_h_roundtrip() {
// Acceptance criterion: Round-trip with Identity-H CMap fixture
// Identity-H CMap typically has a single 2-byte codespace range
let identity_h_cmap = b"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CMapName /Identity-H def
/CMapType 2 def
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
1 begincidchar
<0000> 0
endcidchar
endcmap
CMapName currentdict /CMap defineresource pop
end
end";
let parser = CodespaceParser::new(identity_h_cmap);
let (ranges, diags) = parser.parse();
// Identity-H should have a single 2-byte range covering all 16-bit codes
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 2);
assert_eq!(ranges.ranges[0].lo_slice(), &[0x00, 0x00]);
assert_eq!(ranges.ranges[0].hi_slice(), &[0xFF, 0xFF]);
assert!(diags.is_empty());
// Verify that codes in this range are correctly identified
let range = ranges.find_range(&[0x00, 0x41]).unwrap();
assert_eq!(range.width, 2);
let range = ranges.find_range(&[0xFF, 0xFF]).unwrap();
assert_eq!(range.width, 2);
let range = ranges.find_range(&[0x81, 0x40]).unwrap();
assert_eq!(range.width, 2);
}
#[test]
fn test_identity_v_roundtrip() {
// Identity-V CMap similar to Identity-H but for vertical writing mode
let identity_v_cmap = b"/CIDInit /ProcSet findresource begin
12 dict begin
begincmap
/CMapName /Identity-V def
/CMapType 2 def
1 begincodespacerange
<0000> <FFFF>
endcodespacerange
endcmap
CMapName currentdict /CMap defineresource pop
end
end";
let parser = CodespaceParser::new(identity_v_cmap);
let (ranges, diags) = parser.parse();
// Identity-V should have the same codespace as Identity-H
assert_eq!(ranges.len(), 1);
assert_eq!(ranges.ranges[0].width, 2);
assert!(diags.is_empty());
}
}