fix(extractor): Skip white-fill invisible text to eliminate "A"/"AA" artifacts

Some PDF generators (e.g., 3B2 Total Publishing) use character code 0x41
('A') painted with white fill color (1 g) as invisible spacing. Our
extractor was emitting these as literal "A" characters, producing
pervasive "AA" artifacts throughout the output.

Track fill color state (g/rg/k operators) with save/restore via q/Q,
and skip text items when fill is white (>0.95 gray, or equivalent in
RGB/CMYK). Text matrix advancement is preserved so subsequent visible
text positions correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-15 21:34:18 -08:00
co-authored by Claude Opus 4.6
parent 1ad903e956
commit eb0168f77b
+131 -57
View File
@@ -973,7 +973,8 @@ fn extract_page_text_items(
// Graphics state tracking // Graphics state tracking
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
let mut ctm_stack: Vec<[f32; 6]> = Vec::new(); let mut fill_is_white = false; // Fill color is white (invisible text)
let mut gstate_stack: Vec<([f32; 6], bool)> = Vec::new();
// Text state tracking // Text state tracking
let mut current_font = String::new(); let mut current_font = String::new();
@@ -986,12 +987,13 @@ fn extract_page_text_items(
match op.operator.as_str() { match op.operator.as_str() {
"q" => { "q" => {
// Save graphics state // Save graphics state
ctm_stack.push(ctm); gstate_stack.push((ctm, fill_is_white));
} }
"Q" => { "Q" => {
// Restore graphics state // Restore graphics state
if let Some(saved) = ctm_stack.pop() { if let Some((saved_ctm, saved_fill)) = gstate_stack.pop() {
ctm = saved; ctm = saved_ctm;
fill_is_white = saved_fill;
} }
} }
"cm" => { "cm" => {
@@ -1008,6 +1010,31 @@ fn extract_page_text_items(
ctm = multiply_matrices(&new_matrix, &ctm); ctm = multiply_matrices(&new_matrix, &ctm);
} }
} }
"g" => {
// Set grayscale fill color (1.0 = white)
if let Some(gray) = op.operands.first().and_then(get_number) {
fill_is_white = gray > 0.95;
}
}
"rg" => {
// Set RGB fill color
if op.operands.len() >= 3 {
let r = get_number(&op.operands[0]).unwrap_or(0.0);
let g = get_number(&op.operands[1]).unwrap_or(0.0);
let b = get_number(&op.operands[2]).unwrap_or(0.0);
fill_is_white = r > 0.95 && g > 0.95 && b > 0.95;
}
}
"k" => {
// Set CMYK fill color (0,0,0,0 = white)
if op.operands.len() >= 4 {
let c = get_number(&op.operands[0]).unwrap_or(1.0);
let m = get_number(&op.operands[1]).unwrap_or(1.0);
let y = get_number(&op.operands[2]).unwrap_or(1.0);
let k = get_number(&op.operands[3]).unwrap_or(1.0);
fill_is_white = c < 0.05 && m < 0.05 && y < 0.05 && k < 0.05;
}
}
"BT" => { "BT" => {
// Begin text block // Begin text block
in_text_block = true; in_text_block = true;
@@ -1059,6 +1086,21 @@ fn extract_page_text_items(
"Tj" => { "Tj" => {
// Show text string // Show text string
if in_text_block && !op.operands.is_empty() { if in_text_block && !op.operands.is_empty() {
// Skip invisible (white) text but still advance text matrix
if fill_is_white {
if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
);
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
}
}
continue;
}
if let Some(text) = extract_text_from_operand( if let Some(text) = extract_text_from_operand(
&op.operands[0], &op.operands[0],
doc, doc,
@@ -1072,10 +1114,8 @@ fn extract_page_text_items(
if !text.trim().is_empty() { if !text.trim().is_empty() {
let rendered_size = let rendered_size =
effective_font_size(current_font_size, &text_matrix); effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
// Compute width from font widths if available
let width = if let Some(font_info) = font_widths.get(&current_font) { let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) { if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts( let w_ts = compute_string_width_ts(
@@ -1083,10 +1123,8 @@ fn extract_page_text_items(
font_info, font_info,
current_font_size, current_font_size,
); );
// Advance text matrix by string width
text_matrix[4] += w_ts * text_matrix[0]; text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1]; text_matrix[5] += w_ts * text_matrix[1];
// Transform width through text matrix and CTM
(w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2])) (w_ts * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2]))
.abs() .abs()
} else { } else {
@@ -1095,7 +1133,6 @@ fn extract_page_text_items(
} else { } else {
0.0 0.0
}; };
// Detect bold/italic from font name
let base_font = font_base_names let base_font = font_base_names
.get(&current_font) .get(&current_font)
.map(|s| s.as_str()) .map(|s| s.as_str())
@@ -1125,13 +1162,11 @@ fn extract_page_text_items(
// Compute space threshold based on font metrics when available // Compute space threshold based on font metrics when available
let space_threshold = if let Some(font_info) = font_info { let space_threshold = if let Some(font_info) = font_info {
// Use 40% of the font's space width (in thousandths of text space)
let space_em = font_info.space_width as f32 * font_info.units_scale; let space_em = font_info.space_width as f32 * font_info.units_scale;
let threshold = space_em * 1000.0 * 0.4; let threshold = space_em * 1000.0 * 0.4;
// Clamp to reasonable range: at least 80, at most 200
threshold.clamp(80.0, 200.0) threshold.clamp(80.0, 200.0)
} else { } else {
120.0 // fallback threshold 120.0
}; };
let mut combined_text = String::new(); let mut combined_text = String::new();
@@ -1140,9 +1175,9 @@ fn extract_page_text_items(
match element { match element {
Object::Integer(n) => { Object::Integer(n) => {
let n_val = *n as f32; let n_val = *n as f32;
// Track displacement for total width
total_width_ts += -n_val / 1000.0 * current_font_size; total_width_ts += -n_val / 1000.0 * current_font_size;
if n_val < -space_threshold if !fill_is_white
&& n_val < -space_threshold
&& !combined_text.is_empty() && !combined_text.is_empty()
&& !combined_text.ends_with(' ') && !combined_text.ends_with(' ')
{ {
@@ -1152,7 +1187,8 @@ fn extract_page_text_items(
} }
Object::Real(n) => { Object::Real(n) => {
total_width_ts += -(*n) / 1000.0 * current_font_size; total_width_ts += -(*n) / 1000.0 * current_font_size;
if *n < -space_threshold if !fill_is_white
&& *n < -space_threshold
&& !combined_text.is_empty() && !combined_text.is_empty()
&& !combined_text.ends_with(' ') && !combined_text.ends_with(' ')
{ {
@@ -1162,32 +1198,32 @@ fn extract_page_text_items(
} }
_ => {} _ => {}
} }
// Compute string width for total
if let Some(fi) = font_info { if let Some(fi) = font_info {
if let Some(raw_bytes) = get_operand_bytes(element) { if let Some(raw_bytes) = get_operand_bytes(element) {
total_width_ts += total_width_ts +=
compute_string_width_ts(raw_bytes, fi, current_font_size); compute_string_width_ts(raw_bytes, fi, current_font_size);
} }
} }
if let Some(text) = extract_text_from_operand( if !fill_is_white {
element, if let Some(text) = extract_text_from_operand(
doc, element,
&fonts, doc,
&current_font, &fonts,
font_cmaps, &current_font,
&font_base_names, font_cmaps,
&font_tounicode_refs, &font_base_names,
&font_encodings, &font_tounicode_refs,
) { &font_encodings,
combined_text.push_str(&text); ) {
combined_text.push_str(&text);
}
} }
} }
if !combined_text.trim().is_empty() { if !fill_is_white && !combined_text.trim().is_empty() {
let rendered_size = let rendered_size =
effective_font_size(current_font_size, &text_matrix); effective_font_size(current_font_size, &text_matrix);
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
// Compute accurate width if font widths available
let width = if font_info.is_some() { let width = if font_info.is_some() {
(total_width_ts (total_width_ts
* (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2])) * (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2]))
@@ -1212,11 +1248,11 @@ fn extract_page_text_items(
is_italic: is_italic_font(base_font), is_italic: is_italic_font(base_font),
item_type: ItemType::Text, item_type: ItemType::Text,
}); });
// Advance text matrix by total width }
if font_info.is_some() { // Always advance text matrix by total width
text_matrix[4] += total_width_ts * text_matrix[0]; if font_info.is_some() {
text_matrix[5] += total_width_ts * text_matrix[1]; text_matrix[4] += total_width_ts * text_matrix[0];
} text_matrix[5] += total_width_ts * text_matrix[1];
} }
} }
} }
@@ -1225,7 +1261,7 @@ fn extract_page_text_items(
// Move to next line and show text // Move to next line and show text
line_matrix[5] -= current_font_size * 1.2; line_matrix[5] -= current_font_size * 1.2;
text_matrix = line_matrix; text_matrix = line_matrix;
if !op.operands.is_empty() { if !fill_is_white && !op.operands.is_empty() {
if let Some(text) = extract_text_from_operand( if let Some(text) = extract_text_from_operand(
&op.operands[0], &op.operands[0],
doc, doc,
@@ -1239,10 +1275,8 @@ fn extract_page_text_items(
if !text.trim().is_empty() { if !text.trim().is_empty() {
let rendered_size = let rendered_size =
effective_font_size(current_font_size, &text_matrix); effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
// Detect bold/italic from font name
let base_font = font_base_names let base_font = font_base_names
.get(&current_font) .get(&current_font)
.map(|s| s.as_str()) .map(|s| s.as_str())
@@ -1446,6 +1480,7 @@ fn extract_form_xobject_text(
let mut current_font_size: f32 = 12.0; let mut current_font_size: f32 = 12.0;
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
let mut in_text_block = false; let mut in_text_block = false;
let mut fill_is_white = false;
for op in &content.operations { for op in &content.operations {
match op.operator.as_str() { match op.operator.as_str() {
@@ -1480,8 +1515,44 @@ fn extract_form_xobject_text(
} }
} }
} }
"g" => {
if let Some(gray) = op.operands.first().and_then(get_number) {
fill_is_white = gray > 0.95;
}
}
"rg" => {
if op.operands.len() >= 3 {
let r = get_number(&op.operands[0]).unwrap_or(0.0);
let g = get_number(&op.operands[1]).unwrap_or(0.0);
let b = get_number(&op.operands[2]).unwrap_or(0.0);
fill_is_white = r > 0.95 && g > 0.95 && b > 0.95;
}
}
"k" => {
if op.operands.len() >= 4 {
let c = get_number(&op.operands[0]).unwrap_or(1.0);
let m = get_number(&op.operands[1]).unwrap_or(1.0);
let y = get_number(&op.operands[2]).unwrap_or(1.0);
let k = get_number(&op.operands[3]).unwrap_or(1.0);
fill_is_white = c < 0.05 && m < 0.05 && y < 0.05 && k < 0.05;
}
}
"Tj" => { "Tj" => {
if in_text_block && !op.operands.is_empty() { if in_text_block && !op.operands.is_empty() {
if fill_is_white {
if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
);
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
}
}
continue;
}
if let Some(text) = extract_text_from_operand( if let Some(text) = extract_text_from_operand(
&op.operands[0], &op.operands[0],
doc, doc,
@@ -1497,7 +1568,6 @@ fn extract_form_xobject_text(
effective_font_size(current_font_size, &text_matrix); effective_font_size(current_font_size, &text_matrix);
let combined = multiply_matrices(&text_matrix, parent_ctm); let combined = multiply_matrices(&text_matrix, parent_ctm);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
// Compute width from font widths if available
let width = if let Some(font_info) = font_widths.get(&current_font) { let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) { if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts( let w_ts = compute_string_width_ts(
@@ -1543,7 +1613,6 @@ fn extract_form_xobject_text(
if let Ok(array) = op.operands[0].as_array() { if let Ok(array) = op.operands[0].as_array() {
let font_info = font_widths.get(&current_font); let font_info = font_widths.get(&current_font);
// Compute space threshold based on font metrics when available
let space_threshold = if let Some(fi) = font_info { let space_threshold = if let Some(fi) = font_info {
let space_em = fi.space_width as f32 * fi.units_scale; let space_em = fi.space_width as f32 * fi.units_scale;
let threshold = space_em * 1000.0 * 0.4; let threshold = space_em * 1000.0 * 0.4;
@@ -1559,7 +1628,8 @@ fn extract_form_xobject_text(
Object::Integer(n) => { Object::Integer(n) => {
let n_val = *n as f32; let n_val = *n as f32;
total_width_ts += -n_val / 1000.0 * current_font_size; total_width_ts += -n_val / 1000.0 * current_font_size;
if n_val < -space_threshold if !fill_is_white
&& n_val < -space_threshold
&& !combined_text.is_empty() && !combined_text.is_empty()
&& !combined_text.ends_with(' ') && !combined_text.ends_with(' ')
{ {
@@ -1569,7 +1639,8 @@ fn extract_form_xobject_text(
} }
Object::Real(n) => { Object::Real(n) => {
total_width_ts += -(*n) / 1000.0 * current_font_size; total_width_ts += -(*n) / 1000.0 * current_font_size;
if *n < -space_threshold if !fill_is_white
&& *n < -space_threshold
&& !combined_text.is_empty() && !combined_text.is_empty()
&& !combined_text.ends_with(' ') && !combined_text.ends_with(' ')
{ {
@@ -1585,20 +1656,22 @@ fn extract_form_xobject_text(
compute_string_width_ts(raw_bytes, fi, current_font_size); compute_string_width_ts(raw_bytes, fi, current_font_size);
} }
} }
if let Some(text) = extract_text_from_operand( if !fill_is_white {
element, if let Some(text) = extract_text_from_operand(
doc, element,
&form_fonts, doc,
&current_font, &form_fonts,
font_cmaps, &current_font,
&font_base_names, font_cmaps,
&font_tounicode_refs, &font_base_names,
&font_encodings, &font_tounicode_refs,
) { &font_encodings,
combined_text.push_str(&text); ) {
combined_text.push_str(&text);
}
} }
} }
if !combined_text.trim().is_empty() { if !fill_is_white && !combined_text.trim().is_empty() {
let rendered_size = let rendered_size =
effective_font_size(current_font_size, &text_matrix); effective_font_size(current_font_size, &text_matrix);
let combined_mat = multiply_matrices(&text_matrix, parent_ctm); let combined_mat = multiply_matrices(&text_matrix, parent_ctm);
@@ -1628,10 +1701,11 @@ fn extract_form_xobject_text(
is_italic: is_italic_font(base_font), is_italic: is_italic_font(base_font),
item_type: ItemType::Text, item_type: ItemType::Text,
}); });
if font_info.is_some() { }
text_matrix[4] += total_width_ts * text_matrix[0]; // Always advance text matrix
text_matrix[5] += total_width_ts * text_matrix[1]; if font_info.is_some() {
} text_matrix[4] += total_width_ts * text_matrix[0];
text_matrix[5] += total_width_ts * text_matrix[1];
} }
} }
} }