mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
498d17f72e
commit
ac7f0eebf8
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user