PDF import and export: keep invisible text (rendering mode 3) as type (#2675)

Text drawn in rendering mode 3 (3 Tr), which is how a scanned page carries
its recognized OCR text, was left out on open (since #2988, with a warning),
so a scanned PDF opened and saved again lost its searchable text.

Import: an invisible glyph now comes in as type with neither fill nor
stroke. It skips the glyph-outline checks that guard how visible type looks,
and it joins only other invisible text on a line (Look::takes no longer
lets it into a stroke-only run). INVISIBLE_TEXT_NOTE now only reports text
that can't be type (text opened as outlines, Type 3 fonts), and
tests_reader_compat::invisible_text_is_reported_when_left_out expects the
invisible line as unpainted type when text opens as type.

Export and print: characters with no fill and no stroke are written as real
text inside a form marked Mark::Invisible; forms::finish sets that form's
text rendering mode to 3, so it draws nothing but stays selectable and
searchable (PDF/X-1a and PDF/A-2b too). The writer has no invisible mode of
its own, so this reuses the marked-form mechanism used for layers and
overprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brian Torres
2026-10-20 04:43:11 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 498d17f72e
commit ac7f0eebf8
11 changed files with 251 additions and 44 deletions
+17 -2
View File
@@ -130,10 +130,10 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha
w.page(&mut ex, &sheet).map_err(|_| PdfError::BadArtboard(i))?;
}
vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?;
let (layers, overprinted) = (ex.layers, ex.overprinted);
let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible);
w.absorb(ex);
let (bytes, more) = w.finish(left_out)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted)?;
let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?;
// The writer gives the file its creation date as its modification date too.
let bytes = match opts.modified.filter(|m| Some(*m) != created_at) {
Some(modified) => crate::post::modification_date(bytes, modified)?,
@@ -597,6 +597,8 @@ pub(crate) struct Exporter<'a> {
pub overprint: bool,
/// An overprinting fill or stroke was drawn so.
pub overprinted: bool,
/// Characters with neither fill nor stroke were written as text in forms marked invisible.
pub invisible: bool,
/// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in
/// text…): deeper ones are left out.
inline_depth: u32,
@@ -639,6 +641,7 @@ impl<'a> Exporter<'a> {
in_layer: 0,
overprint: false,
overprinted: false,
invisible: false,
inline_depth: 0,
left_out: HashSet::new(),
}
@@ -1564,6 +1567,18 @@ impl Exporter<'_> {
}
s.pop();
});
// Characters with neither fill nor stroke (a scanned page's recognized text) are still
// text: written invisible (rendering mode 3), so they can still be selected and searched.
if let Some(text) = plain.as_deref().filter(|_| fill.is_none() && !run.style.has_stroke()) {
self.invisible = true;
crate::forms::form(s, Mark::Invisible, |s| {
s.push_transform(&text_space);
// The paint only lets the writer write the text; the glyphs it can't write
// as text draw nothing.
self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)));
s.pop();
});
}
s.push_transform(&text_space);
let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten();
s.pop();
+62 -10
View File
@@ -1,9 +1,10 @@
//! What the PDF writer has no operators for — optional content (PDF layers) and overprint — drawn
//! as form XObjects of their own (without a transparency group, so they draw as if in place),
//! each starting with a mark: an empty clip whose first point says what the form is. Marked
//! forms also stay apart (the writer shares identical forms). [`finish`] completes them in the
//! written file: a layer's form is drawn as the layer's optional content (`/OC … BDC`), and an
//! overprinting form sets a graphics state that overprints first.
//! What the PDF writer has no operators for — optional content (PDF layers), overprint and
//! invisible text — drawn as form XObjects of their own (without a transparency group, so they
//! draw as if in place), each starting with a mark: an empty clip whose first point says what the
//! form is. Marked forms also stay apart (the writer shares identical forms). [`finish`] completes
//! them in the written file: a layer's form is drawn as the layer's optional content (`/OC …
//! BDC`), an overprinting form sets a graphics state that overprints first, and the text of an
//! invisible form is drawn in rendering mode 3 (neither filled nor stroked).
use std::collections::HashMap;
use std::io::Read;
@@ -28,6 +29,8 @@ pub(crate) enum Mark {
Layer(usize),
/// A fill or stroke that overprints.
Overprint,
/// Text that draws nothing (rendering mode 3): characters with neither fill nor stroke.
Invisible,
}
/// A layer written as a PDF layer (an optional content group).
@@ -58,6 +61,8 @@ pub(crate) fn pdf_layers(doc: &Document) -> Vec<PdfLayer<'_>> {
const LAYER_X: i32 = -32000;
/// The x coordinate of an overprint mark's first point.
const OVERPRINT_X: i32 = -32001;
/// The x coordinate of an invisible text mark's first point.
const INVISIBLE_X: i32 = -32002;
impl Mark {
fn at(self) -> (f32, f32) {
@@ -65,6 +70,7 @@ impl Mark {
// Exact in `f32` up to 2^24 layers.
Self::Layer(i) => (LAYER_X as f32, i as f32),
Self::Overprint => (OVERPRINT_X as f32, 0.0),
Self::Invisible => (INVISIBLE_X as f32, 0.0),
}
}
}
@@ -100,7 +106,7 @@ const LAYER_OC: &str = "VCoc";
const HEAD: u64 = 64;
fn bad(why: &str) -> PdfError {
PdfError::Write(format!("can't write PDF layers or overprint: {why}"))
PdfError::Write(format!("can't write PDF layers, overprint or invisible text: {why}"))
}
/// An object of the written file with a dictionary, and its stream's [`StreamSpans`].
@@ -149,6 +155,7 @@ impl Object<'_> {
match x {
_ if x == i64::from(LAYER_X) => usize::try_from(y).ok().map(Mark::Layer),
_ if x == i64::from(OVERPRINT_X) && y == 0 => Some(Mark::Overprint),
_ if x == i64::from(INVISIBLE_X) && y == 0 => Some(Mark::Invisible),
_ => None,
}
}
@@ -225,14 +232,52 @@ fn mark_layers(content: &[u8], forms: &HashMap<&[u8], usize>) -> Option<(Vec<u8>
Some((out, layers))
}
/// `content` (an invisible form's) with its text drawn in rendering mode 3: the mode set first,
/// and every mode the writer sets (it sets one for each run of glyphs) made 3. `None` when the
/// content can't be read to its end.
fn invisible_text(content: &[u8]) -> Option<Vec<u8>> {
let mut lx = Lexer::at(content, 0);
let mut operand = None;
let mut modes = vec![];
let mut read = 0;
while let Some((tok, start, end)) = lx.next() {
read = end;
operand = match tok {
Tok::Word(b"Tr") => {
modes.extend(operand);
None
}
Tok::Word(w) if int(w).is_some() => Some((start, end)),
// Inline image data isn't PDF syntax (the writer writes none).
Tok::Word(b"ID") => return None,
_ => None,
};
}
// The lexer also stops at a string it can't read: a mode after it would be missed.
if !content.get(read..)?.iter().all(u8::is_ascii_whitespace) {
return None;
}
let mut out = Vec::with_capacity(content.len() + 8);
out.extend_from_slice(b"3 Tr\n");
let mut at = 0;
for (start, end) in modes {
out.extend_from_slice(content.get(at..start)?);
out.push(b'3');
at = end;
}
out.extend_from_slice(content.get(at..)?);
Some(out)
}
/// Finish the marked forms of `pdf` (as [`crate::export`] writes `doc` with `set`): with
/// `layers`, each layer and sublayer (but template layers) becomes an optional content group —
/// named as the layer, off when it is hidden, not printed (`/PrintState /OFF`) when its Print
/// option is off, locked when it is locked — and the uses of its forms its optional content;
/// with `overprint`, the overprinting forms get a graphics state that overprints (`/OP`, `/op`,
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours).
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool) -> Result<Vec<u8>, PdfError> {
if !layers && !overprint {
/// `/OPM 1`; `/OPM 0` in PDF/A, which allows no other with ICC-based CMYK colours); with
/// `invisible`, the text of the invisible forms is drawn in rendering mode 3.
pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bool, overprint: bool, invisible: bool) -> Result<Vec<u8>, PdfError> {
if !layers && !overprint && !invisible {
return Ok(pdf);
}
let xref = Xref::read(&pdf).ok_or_else(|| bad("the written PDF has no cross-reference table"))?;
@@ -253,6 +298,13 @@ pub(crate) fn finish(pdf: Vec<u8>, doc: &Document, set: &PdfSettings, layers: bo
add_resources(&mut patch, res, "ExtGState", &format!("/{OVERPRINT_GS} {gs} 0 R"))?;
}
}
if invisible {
for (n, _) in marks.iter().filter(|(_, m)| **m == Mark::Invisible) {
let Some(o) = objects.get(n) else { continue };
let content = o.data(&pdf, u64::MAX).ok_or_else(|| bad("an invisible text form can't be read"))?;
o.rewrite(&mut patch, &invisible_text(&content).ok_or_else(|| bad("an invisible text form can't be read"))?)?;
}
}
if layers {
write_layers(&pdf, &xref, &objects, &marks, &mut patch, doc, set)?;
for (n, _) in marks.iter().filter(|(_, m)| matches!(m, Mark::Layer(_))) {
+34 -16
View File
@@ -149,10 +149,10 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it,
/// Warning: glyphs were kept as outlines rather than read as type.
pub const OUTLINED_TEXT_NOTE: &str = "text was converted to outlines";
/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) was left
/// out: VectorCraft has no type that is kept but not drawn, so a PDF saved from it loses that
/// searchable text (#2675).
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) was left out";
/// Warning: text drawn invisibly (text rendering mode 3, as a scanned page's OCR layer is) that
/// can't come in as type (text read as outlines, or a Type 3 font) was left out: as outlines it
/// would draw nothing (#2675).
pub const INVISIBLE_TEXT_NOTE: &str = "invisible text (such as a scanned page's searchable OCR layer) that couldn't be read as type was left out";
/// Warning: glyphs that differ from the installed font of their name were kept as outlines.
pub const UNMATCHED_GLYPHS_NOTE: &str = "text whose glyphs differ from the installed font of the same name was kept as outlines";
/// The end of the warning that glyphs whose characters the file doesn't name were kept as
@@ -1654,13 +1654,14 @@ impl<'p> Builder<'p> {
}
/// Glyph `o` as type: added to the line being gathered (or starting one). `false`: it keeps
/// its outline (no Unicode text, mirrored, or not type mode).
/// its outline (no Unicode text, mirrored, or not type mode). No `paint`: invisible text,
/// type with neither fill nor stroke.
fn type_glyph(
&mut self,
o: &craft_pdf::interpret::font::OutlineGlyph,
m: Affine,
scale: f64,
paint: &craft_pdf::interpret::Paint<'_>,
paint: Option<&craft_pdf::interpret::Paint<'_>>,
stroke: Option<&StrokeProps>,
overprint: bool,
) -> bool {
@@ -1692,7 +1693,8 @@ impl<'p> Builder<'p> {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None => {
if o.as_unicode().is_some_and(|u| replacement(&u)) {
// Invisible text isn't kept as outlines: the caller says it was left out.
if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) {
let name = self.font_name(key, o).family;
self.warn(&format!("text in {name} {UNNAMED_CHARACTERS_NOTE}"));
}
@@ -1718,16 +1720,18 @@ impl<'p> Builder<'p> {
{
let decided = match self.matched.get(&key) {
Some(m) => m.clone(),
// A glyph that can't be compared (several characters, a vertical form) decides
// nothing: it takes the face of its name until one that can be compared does.
None if comparable(&text, upright).is_none() => Some(face.clone()),
// A glyph that can't be compared (several characters, a vertical form) or that
// shows nothing (invisible text) decides nothing: it takes the face of its name
// until one that can be compared does.
None if paint.is_none() || comparable(&text, upright).is_none() => Some(face.clone()),
None => {
let m = matching_face(face, &text, o, upright);
self.matched.insert(key, m.clone());
m
}
};
let Some(f) = decided else {
// Invisible text shows no glyphs that could differ: it keeps the face of its name.
let Some(f) = decided.or_else(|| paint.is_none().then(|| face.clone())) else {
self.warn(UNMATCHED_GLYPHS_NOTE);
return false;
};
@@ -1745,8 +1749,14 @@ impl<'p> Builder<'p> {
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
None => named(&info.face),
};
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
let stroke = stroke.map(|p| (paint.clone(), line_width(p, scale)));
let (paint, opacity) = match paint {
Some(p) => {
let (paint, opacity) = self.paint(p, stroke.is_some(), self.walked(true));
(Some(paint), opacity)
}
None => (None, 1.0),
};
let stroke = stroke.zip(paint.clone()).map(|(p, paint)| (paint, line_width(p, scale)));
let look = Look {
font: key,
family: info.family,
@@ -1756,7 +1766,7 @@ impl<'p> Builder<'p> {
h_scale: at.h_scale,
overprint_fill: stroke.is_none() && overprint,
overprint_stroke: stroke.is_some() && overprint,
fill: stroke.is_none().then_some(paint),
fill: if stroke.is_none() { paint } else { None },
stroke,
};
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
@@ -1967,8 +1977,16 @@ impl<'a> Device<'a> for Builder<'_> {
) {
self.pending = false;
let stroke = match draw_mode {
// Invisible text (a scanned page's recognized text, over the scan) draws nothing but
// is still text: type with neither fill nor stroke, so it stays searchable.
GlyphDrawMode::Invisible => {
self.warn(INVISIBLE_TEXT_NOTE);
let typed = match glyph {
Glyph::Outline(o) => self.type_glyph(o, transform * glyph_transform, mean_scale(transform), None, None, false),
Glyph::Type3(_) => false,
};
if !typed {
self.warn(INVISIBLE_TEXT_NOTE);
}
return;
}
GlyphDrawMode::Fill => None,
@@ -1978,7 +1996,7 @@ impl<'a> Device<'a> for Builder<'_> {
Glyph::Outline(o) => {
let scale = mean_scale(transform);
let overprint = self.glyph_overprint(stroke.is_some());
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
if self.type_glyph(o, transform * glyph_transform, scale, Some(paint), stroke.as_ref(), overprint) {
return;
}
self.flush_text();
+3 -2
View File
@@ -88,14 +88,15 @@ pub(crate) struct Look {
impl Look {
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice); invisible
/// text (neither fill nor stroke) joins only invisible text.
fn takes(&self, other: &Look) -> bool {
self.font == other.font
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& self.overprint_fill == other.overprint_fill
&& (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
&& ((other.stroke.is_none() && other.fill.is_some()) || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
}
fn style(&self) -> CharStyle {
+2 -1
View File
@@ -422,11 +422,12 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin
w.page(ex, &s.sheet())?;
}
let overprinted = exporters.iter().any(|ex| ex.overprinted);
let invisible = exporters.iter().any(|ex| ex.invisible);
for ex in exporters {
w.absorb(ex);
}
let (bytes, more) = w.finish(left_out)?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted)?;
let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?;
let mut warnings = plan.warnings;
for m in more {
if !warnings.contains(&m) {
+37
View File
@@ -309,6 +309,43 @@ fn text_becomes_point_type() {
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
}
/// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is
/// type with neither fill nor stroke, and the visible text keeps its paint. On a line with
/// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left
/// out, and the open says so (#2675).
#[test]
fn invisible_text_becomes_type_without_paint() {
let bytes = one_page(
"0.5 g 10 40 80 40 re f \
BT /F1 10 Tf 3 Tr 12 70 Td (Invisible one) Tj 0 -12 Td (Invisible two) Tj ET \
BT /F1 10 Tf 0 g 0 Tr 12 20 Td (Visible) Tj ET \
BT /F1 10 Tf 1 0 0 RG 1 Tr 12 90 Td (Out) Tj 3 Tr (line) Tj ET",
HELVETICA,
&[],
);
let r = open_with(&bytes, |_| {});
let t = texts(&r.document);
let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::<String>();
assert_eq!(t.iter().map(text).collect::<Vec<_>>(), ["Invisible one", "Invisible two", "Visible", "Outline"], "{t:?}");
let unpainted = |st: &vectorcraft_doc::CharStyle| st.fill.is_none() && !st.has_stroke();
for o in &t[..2] {
assert!(o.runs.iter().all(|r| unpainted(&r.style)), "{:?}", o.runs);
}
assert!(t[2].runs.iter().all(|r| !r.style.fill.is_none()), "{:?}", t[2].runs);
let runs: Vec<(&str, bool)> = t[3].runs.iter().map(|r| (r.text.as_str(), unpainted(&r.style))).collect();
assert_eq!(runs, [("Out", false), ("line", true)]);
assert!(t[3].runs[0].style.has_stroke());
assert!(!r.warnings.iter().any(|w| w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
// As outlines: only the visible text's.
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
assert!(texts(&r.document).is_empty());
assert!(r.warnings.iter().any(|w| w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
let outlines: Vec<Rect> =
all(&r.document).iter().filter(|n| n.name.as_deref() == Some("<Text Outlines>")).filter_map(|n| n.geometric_bounds()).collect();
// y down: "Visible" sits on 80, "Out" on 10; the invisible lines on 30 and 42 left nothing.
assert!(!outlines.is_empty() && outlines.iter().all(|b| b.y1 < 20.0 || b.y0 > 70.0), "{outlines:?}");
}
#[test]
fn font_names_resolve_to_available_families() {
let f = crate::import_text::Families::available();
+26
View File
@@ -160,6 +160,32 @@ fn a_glyph_whose_unicode_names_another_letter_stays_outlined() {
assert!(r.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", r.warnings);
}
/// Invisible text (rendering mode 3) shows no glyphs that could differ from the installed font:
/// the glyph of `l` labelled `W` comes in as type showing `W`, and nothing is said to be kept as
/// outlines (#2675).
#[test]
fn invisible_text_is_type_whatever_its_glyphs_look_like() {
let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
let l = font.glyph_for('l');
let mut pdf = krilla::Document::new_with(krilla::SerializeSettings { compress_content_streams: false, ..Default::default() });
let mut page = pdf.start_page_with(PageSettings::from_wh(300.0, 200.0).unwrap());
let mut s = page.surface();
let kfont = Font::new(SOURCE_SANS.into(), 0).unwrap();
let glyph = krilla::text::KrillaGlyph::new(krilla::text::GlyphId::new(l), 0.25, 0.0, 0.0, 0.0, 0..1, None);
s.draw_glyphs(KPoint::from_xy(20.0, 50.0), &[glyph], kfont, "W", 24.0, false);
s.finish();
page.finish();
let mut bytes = pdf.finish().unwrap();
// Filled (mode 0) made invisible: the same length, so the cross-reference table still holds.
let at = bytes.windows(4).position(|w| w == b"0 Tr").expect("a rendering mode");
bytes[at] = b'3';
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let t = texts(&r.document);
assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), ["W"]);
assert!(t[0].runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()), "{:?}", t[0].runs);
assert!(!r.warnings.iter().any(|w| is_outlines_note(w) || w == INVISIBLE_TEXT_NOTE), "{:?}", r.warnings);
}
/// `font` with each of `names` (`from`, `to`, the same length) replaced, in ASCII and UTF-16.
fn renamed(font: &[u8], names: &[(&str, &str)]) -> Vec<u8> {
let mut bytes = font.to_vec();
+20 -10
View File
@@ -178,31 +178,41 @@ fn soft_mask_inversion_uses_quantized_transfer_endpoints() {
transfer_contract();
}
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) isn't kept, and the
/// import now says so, for type and outlines alike. Visible text beside it still comes in, and a
/// page without invisible text gets no such warning.
/// #2675: text in rendering mode 3 (invisible, as a scanned page's OCR layer) comes in as type
/// with neither fill nor stroke. Where it can't come in as type (text read as outlines) it is
/// left out, and the import says so. Visible text beside it still comes in, and a page without
/// invisible text gets no such warning.
#[test]
fn invisible_text_is_reported_when_left_out() {
let open = |content: &str, text_as: TextAs| import_with_report(&text_pdf(content), &ImportOptions { text_as, ..Default::default() }).unwrap();
let invisible = "BT /F1 20 Tf 3 Tr 1 0 0 1 10 40 Tm (AB) Tj 0 Tr 1 0 0 1 10 10 Tm (CA) Tj ET";
for text_as in [TextAs::Text, TextAs::Outlines] {
let r = open(invisible, text_as);
assert!(r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE), "{text_as:?}: {:?}", r.warnings);
// What came in: the type's text, and where the art lies. On the 60 pt page the visible
// line (baseline 10 pt up) lies 35–50 pt down, the invisible one (40 pt up) 5–20 pt down.
let (mut text, mut tops) = (String::new(), vec![]);
let warned = r.warnings.iter().any(|w| w == crate::INVISIBLE_TEXT_NOTE);
assert_eq!(warned, text_as == TextAs::Outlines, "{text_as:?}: {:?}", r.warnings);
// What came in: the type's text and whether it is painted, and where the painted art
// lies. On the 60 pt page the visible line (baseline 10 pt up) lies 35–50 pt down, the
// invisible one (40 pt up) 5–20 pt down.
let (mut painted, mut unpainted, mut tops) = (String::new(), String::new(), vec![]);
r.document.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
text.push_str(&t.plain_text());
for run in &t.runs {
let out = if run.style.fill.is_none() && !run.style.has_stroke() { &mut unpainted } else { &mut painted };
out.push_str(&run.text);
}
}
if matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
let shows = !matches!(&n.kind, NodeKind::Text(t) if t.runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()));
if shows
&& matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. } | NodeKind::Compound { .. })
&& let Some(b) = n.visual_bounds()
{
tops.push(b.y0);
}
});
if text_as == TextAs::Text {
assert!(text.contains("CA") && !text.contains("AB"), "{text_as:?}: {text:?}");
assert_eq!((painted.as_str(), unpainted.as_str()), ("CA", "AB"), "{text_as:?}");
} else {
assert_eq!(unpainted, "", "{text_as:?}");
}
assert!(!tops.is_empty(), "{text_as:?}: the visible line still comes in");
assert!(tops.iter().all(|y| *y > 25.0), "{text_as:?}: only the visible line's art, tops at {tops:?}");
+44
View File
@@ -315,6 +315,50 @@ fn subset_thresholds_below_100_percent_are_accepted_with_a_warning() {
assert_eq!(texts(&import_as(&r.bytes, TextAs::Text)), ["Subset"]);
}
/// Characters with neither fill nor stroke (a scanned page's recognized text, as a PDF opens) are
/// written as invisible text, in rendering mode 3: searchable, drawing nothing, and type without
/// paint again when the file opens. Compressed or not, in PDF/X-1a and PDF/A too (#2675).
#[test]
fn type_without_paint_is_written_as_invisible_text() {
let unpainted = CharStyle { fill: Paint::None, ..style(24.0) };
let d = doc(vec![
TextObject::point(Point::new(20.0, 60.0), "Recognized", unpainted),
TextObject::point(Point::new(20.0, 160.0), "Visible", style(24.0)),
]);
let r = pdf(&d, false);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let ops = String::from_utf8_lossy(&r.bytes).split_whitespace().collect::<Vec<_>>().join(" ");
assert!(ops.contains("3 Tr") && ops.contains("0 Tr"), "the invisible text in mode 3, the visible in mode 0");
let written = |v: serde_json::Value| {
let settings: PdfSettings = serde_json::from_value(v).unwrap();
export(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }).unwrap()
};
let files = [
("uncompressed", r.bytes.clone()),
("compressed", written(json!({"advanced": {"outlineText": false}}))),
("PDF/X-1a", written(json!({"standard": "pdfX1a", "compatibility": "1.3", "advanced": {"outlineText": false}}))),
("PDF/A-2b", written(json!({"standard": "pdfA2b", "advanced": {"outlineText": false}}))),
];
for (what, bytes) in files {
let back = import_as(&bytes, TextAs::Text);
assert_eq!(texts(&back), ["Recognized", "Visible"], "{what}");
let mut paints = vec![];
back.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
paints.push(t.runs.iter().all(|r| r.style.fill.is_none() && !r.style.has_stroke()));
}
});
assert_eq!(paints, [true, false], "{what}: only the recognized text has no paint");
// It draws nothing: as outlines, only the visible text is there (its baseline on 160).
let drawn = ink(&import_as(&bytes, TextAs::Outlines));
assert!(drawn.y0 > 100.0, "{what}: {drawn:?}");
}
// Text exported as outlines has no invisible text: there is nothing to draw.
let outlined = import_as(&pdf(&d, true).bytes, TextAs::Text);
assert!(texts(&outlined).is_empty());
assert!(ink(&outlined).y0 > 100.0);
}
#[test]
fn pdf_a_takes_real_text() {
let d = doc(vec![TextObject::point(Point::new(20.0, 60.0), "Archive", style(24.0))]);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long