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:
co-authored by
Claude Opus 4.6
parent
1ad903e956
commit
eb0168f77b
+103
-29
@@ -973,7 +973,8 @@ fn extract_page_text_items(
|
||||
|
||||
// Graphics state tracking
|
||||
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
|
||||
let mut current_font = String::new();
|
||||
@@ -986,12 +987,13 @@ fn extract_page_text_items(
|
||||
match op.operator.as_str() {
|
||||
"q" => {
|
||||
// Save graphics state
|
||||
ctm_stack.push(ctm);
|
||||
gstate_stack.push((ctm, fill_is_white));
|
||||
}
|
||||
"Q" => {
|
||||
// Restore graphics state
|
||||
if let Some(saved) = ctm_stack.pop() {
|
||||
ctm = saved;
|
||||
if let Some((saved_ctm, saved_fill)) = gstate_stack.pop() {
|
||||
ctm = saved_ctm;
|
||||
fill_is_white = saved_fill;
|
||||
}
|
||||
}
|
||||
"cm" => {
|
||||
@@ -1008,6 +1010,31 @@ fn extract_page_text_items(
|
||||
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" => {
|
||||
// Begin text block
|
||||
in_text_block = true;
|
||||
@@ -1059,6 +1086,21 @@ fn extract_page_text_items(
|
||||
"Tj" => {
|
||||
// Show text string
|
||||
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(¤t_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(
|
||||
&op.operands[0],
|
||||
doc,
|
||||
@@ -1072,10 +1114,8 @@ fn extract_page_text_items(
|
||||
if !text.trim().is_empty() {
|
||||
let rendered_size =
|
||||
effective_font_size(current_font_size, &text_matrix);
|
||||
// Transform position through CTM
|
||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Compute width from font widths if available
|
||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
@@ -1083,10 +1123,8 @@ fn extract_page_text_items(
|
||||
font_info,
|
||||
current_font_size,
|
||||
);
|
||||
// Advance text matrix by string width
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
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]))
|
||||
.abs()
|
||||
} else {
|
||||
@@ -1095,7 +1133,6 @@ fn extract_page_text_items(
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
// Detect bold/italic from font name
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
@@ -1125,13 +1162,11 @@ fn extract_page_text_items(
|
||||
|
||||
// Compute space threshold based on font metrics when available
|
||||
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 threshold = space_em * 1000.0 * 0.4;
|
||||
// Clamp to reasonable range: at least 80, at most 200
|
||||
threshold.clamp(80.0, 200.0)
|
||||
} else {
|
||||
120.0 // fallback threshold
|
||||
120.0
|
||||
};
|
||||
|
||||
let mut combined_text = String::new();
|
||||
@@ -1140,9 +1175,9 @@ fn extract_page_text_items(
|
||||
match element {
|
||||
Object::Integer(n) => {
|
||||
let n_val = *n as f32;
|
||||
// Track displacement for total width
|
||||
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.ends_with(' ')
|
||||
{
|
||||
@@ -1152,7 +1187,8 @@ fn extract_page_text_items(
|
||||
}
|
||||
Object::Real(n) => {
|
||||
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.ends_with(' ')
|
||||
{
|
||||
@@ -1162,13 +1198,13 @@ fn extract_page_text_items(
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Compute string width for total
|
||||
if let Some(fi) = font_info {
|
||||
if let Some(raw_bytes) = get_operand_bytes(element) {
|
||||
total_width_ts +=
|
||||
compute_string_width_ts(raw_bytes, fi, current_font_size);
|
||||
}
|
||||
}
|
||||
if !fill_is_white {
|
||||
if let Some(text) = extract_text_from_operand(
|
||||
element,
|
||||
doc,
|
||||
@@ -1182,12 +1218,12 @@ fn extract_page_text_items(
|
||||
combined_text.push_str(&text);
|
||||
}
|
||||
}
|
||||
if !combined_text.trim().is_empty() {
|
||||
}
|
||||
if !fill_is_white && !combined_text.trim().is_empty() {
|
||||
let rendered_size =
|
||||
effective_font_size(current_font_size, &text_matrix);
|
||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Compute accurate width if font widths available
|
||||
let width = if font_info.is_some() {
|
||||
(total_width_ts
|
||||
* (text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2]))
|
||||
@@ -1212,7 +1248,8 @@ fn extract_page_text_items(
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
// Advance text matrix by total width
|
||||
}
|
||||
// Always advance text matrix by total width
|
||||
if font_info.is_some() {
|
||||
text_matrix[4] += total_width_ts * text_matrix[0];
|
||||
text_matrix[5] += total_width_ts * text_matrix[1];
|
||||
@@ -1220,12 +1257,11 @@ fn extract_page_text_items(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"'" => {
|
||||
// Move to next line and show text
|
||||
line_matrix[5] -= current_font_size * 1.2;
|
||||
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(
|
||||
&op.operands[0],
|
||||
doc,
|
||||
@@ -1239,10 +1275,8 @@ fn extract_page_text_items(
|
||||
if !text.trim().is_empty() {
|
||||
let rendered_size =
|
||||
effective_font_size(current_font_size, &text_matrix);
|
||||
// Transform position through CTM
|
||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Detect bold/italic from font name
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
@@ -1446,6 +1480,7 @@ fn extract_form_xobject_text(
|
||||
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 in_text_block = false;
|
||||
let mut fill_is_white = false;
|
||||
|
||||
for op in &content.operations {
|
||||
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" => {
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if fill_is_white {
|
||||
if let Some(font_info) = font_widths.get(¤t_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(
|
||||
&op.operands[0],
|
||||
doc,
|
||||
@@ -1497,7 +1568,6 @@ fn extract_form_xobject_text(
|
||||
effective_font_size(current_font_size, &text_matrix);
|
||||
let combined = multiply_matrices(&text_matrix, parent_ctm);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Compute width from font widths if available
|
||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
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() {
|
||||
let font_info = font_widths.get(¤t_font);
|
||||
|
||||
// Compute space threshold based on font metrics when available
|
||||
let space_threshold = if let Some(fi) = font_info {
|
||||
let space_em = fi.space_width as f32 * fi.units_scale;
|
||||
let threshold = space_em * 1000.0 * 0.4;
|
||||
@@ -1559,7 +1628,8 @@ fn extract_form_xobject_text(
|
||||
Object::Integer(n) => {
|
||||
let n_val = *n as f32;
|
||||
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.ends_with(' ')
|
||||
{
|
||||
@@ -1569,7 +1639,8 @@ fn extract_form_xobject_text(
|
||||
}
|
||||
Object::Real(n) => {
|
||||
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.ends_with(' ')
|
||||
{
|
||||
@@ -1585,6 +1656,7 @@ fn extract_form_xobject_text(
|
||||
compute_string_width_ts(raw_bytes, fi, current_font_size);
|
||||
}
|
||||
}
|
||||
if !fill_is_white {
|
||||
if let Some(text) = extract_text_from_operand(
|
||||
element,
|
||||
doc,
|
||||
@@ -1598,7 +1670,8 @@ fn extract_form_xobject_text(
|
||||
combined_text.push_str(&text);
|
||||
}
|
||||
}
|
||||
if !combined_text.trim().is_empty() {
|
||||
}
|
||||
if !fill_is_white && !combined_text.trim().is_empty() {
|
||||
let rendered_size =
|
||||
effective_font_size(current_font_size, &text_matrix);
|
||||
let combined_mat = multiply_matrices(&text_matrix, parent_ctm);
|
||||
@@ -1628,6 +1701,8 @@ fn extract_form_xobject_text(
|
||||
is_italic: is_italic_font(base_font),
|
||||
item_type: ItemType::Text,
|
||||
});
|
||||
}
|
||||
// Always advance text matrix
|
||||
if font_info.is_some() {
|
||||
text_matrix[4] += total_width_ts * text_matrix[0];
|
||||
text_matrix[5] += total_width_ts * text_matrix[1];
|
||||
@@ -1635,7 +1710,6 @@ fn extract_form_xobject_text(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user