build: replace printpdf 0.7 with own PDF writer on lopdf 0.46

printpdf 0.7 pinned lopdf 0.31; newer printpdf versions are a complete
rewrite. The export now uses src/pdf_writer.rs, a small writer on top of
lopdf 0.46 with the subset of the printpdf API the export used: pages with
layers (optional content groups), paths, embedded TrueType fonts (Type0,
Identity-H, ToUnicode), images and bookmarks. Bookmark titles are written
as UTF-16BE directly, so the post-processing step for them is gone.
pdf_merge uses lopdf 0.46 as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-27 16:54:13 +01:00
1 parent d610ca019a
commit e47c2b6f28
11 files changed
+1564 -265

No files matched your search

+73 -130
View File
@@ -1,4 +1,4 @@
//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs via printpdf.
//! Verlustfreier Vektor-PDF-Export des gesamten Notizbuchs (PDF-Ausgabe: [`crate::pdf_writer`]).
//!
//! Features:
//! - Hochformat DIN A4 (210 mm x 297 mm)
@@ -8,7 +8,7 @@
//! - Vektor-Hintergründe (Kariert 5mm, Liniert, Punktraster, Blanko)
//! - Zwei Export-Strategien:
//! - `FilledPolygon`: Glatte gefüllte Polygone aus den Triangle-Strip-Hüllen (variable Pinselstärke)
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `printpdf::Line`
//! - `BezierCurve`: Glatte kubische Bézier-Kurven via `pdf_writer::Line`
use std::collections::HashMap;
use std::error::Error;
@@ -16,14 +16,15 @@ use std::fs::File;
use std::io::BufWriter;
use std::path::{Path, PathBuf};
use printpdf::path::{PaintMode, WindingOrder};
use printpdf::{Color, Line, Mm, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb};
use crate::assets::AssetStore;
use crate::background::{PatternColor, DOT_SIZE, GRID_5MM_SPACING, LINED_SPACING};
use crate::document::{
Notebook, Page, PageBackground, Stroke, A4_HEIGHT_MM, A4_WIDTH_MM, PAGE_HEIGHT, PAGE_WIDTH,
};
use crate::pdf_writer::{
Color, Line, Mm, PaintMode, PdfDocument, PdfDocumentReference, Point, Polygon, Rgb,
WindingOrder,
};
use crate::t;
use ab_glyph::Font as _;
@@ -147,19 +148,19 @@ fn pdf_page_mm(page: &Page) -> (Mm, Mm) {
/// Beginnt eine Ebene: Koordinaten beziehen sich auf eine A4-hohe Seite (siehe
/// [`page_to_pdf_mm`]) und werden auf die tatsächliche Höhe von `page` verschoben.
fn begin_page_layer(layer: &printpdf::PdfLayerReference, page: &Page) {
fn begin_page_layer(layer: &crate::pdf_writer::PdfLayerReference, page: &Page) {
layer.save_graphics_state();
let dy = (page.size.y - PAGE_HEIGHT) * PT_PER_UNIT;
if dy != 0.0 {
layer.set_ctm(printpdf::CurTransMat::Translate(
printpdf::Pt(0.0),
printpdf::Pt(dy),
layer.set_ctm(crate::pdf_writer::CurTransMat::Translate(
crate::pdf_writer::Pt(0.0),
crate::pdf_writer::Pt(dy),
));
}
}
/// Beendet eine mit [`begin_page_layer`] begonnene Ebene.
fn end_page_layer(layer: &printpdf::PdfLayerReference) {
fn end_page_layer(layer: &crate::pdf_writer::PdfLayerReference) {
layer.restore_graphics_state();
}
@@ -211,7 +212,7 @@ pub fn export_notebook_to_bytes(
return export_notebook_to_bytes(&cropped, strategy, assets, pattern);
}
let doc = generate_pdf_document(notebook, strategy, assets, pattern)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?;
// Importierte PDF-Seiten als Vektorgrafik unterlegen
let underlays = pdf_underlays(notebook);
@@ -237,7 +238,7 @@ pub fn export_notebook_to_bytes(
raster.insert(underlay.target_page);
}
let doc = generate_pdf_document_with(notebook, strategy, assets, pattern, &raster)?;
let bytes = add_alpha_states(fix_text_encoding(doc.save_to_bytes()?, notebook)?, notebook)?;
let bytes = add_alpha_states(doc.save_to_bytes()?, notebook)?;
let remaining: Vec<_> = underlays
.into_iter()
.filter(|u| !raster.contains(&u.target_page))
@@ -249,35 +250,6 @@ pub fn export_notebook_to_bytes(
Ok(merged)
}
/// printpdf schreibt Lesezeichen-Titel als rohes UTF-8, PDF-Betrachter lesen Textstrings aber
/// als PDFDocEncoding oder UTF-16BE: Umlaute und Gedankenstriche würden verstümmelt. Titel
/// mit Nicht-ASCII-Zeichen werden deshalb nach UTF-16BE (mit BOM) umgeschrieben.
fn fix_text_encoding(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
let needs_fix = notebook
.pages
.iter()
.filter_map(|p| p.title.as_deref())
.any(|t| !t.is_ascii());
if !needs_fix {
return Ok(bytes);
}
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
for object in doc.objects.values_mut() {
let Ok(dict) = object.as_dict_mut() else {
continue;
};
if let Ok(printpdf::lopdf::Object::String(raw, format)) = dict.get_mut(b"Title") {
if let Some(utf16) = utf16be_text(raw) {
*raw = utf16;
*format = printpdf::lopdf::StringFormat::Hexadecimal;
}
}
}
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
/// Name des Grafikzustands für halbtransparente Tinte mit Deckkraft `alpha` (auf Prozent
/// gerundet); `None` für deckende Farben.
fn alpha_state_name(alpha: f32) -> Option<String> {
@@ -285,12 +257,12 @@ fn alpha_state_name(alpha: f32) -> Option<String> {
(percent < 100).then(|| format!("InkAlpha{percent}"))
}
/// printpdf kann keine Grafikzustände mit Deckkraft anlegen: Die Tintenebene verweist mit
/// Grafikzustände mit Deckkraft: Die Tintenebene verweist mit
/// `gs` auf die Namen aus [`alpha_state_name`], die Zustände selbst (`ca`/`CA`) werden hier
/// in die Ressourcen jeder Seite eingetragen. Ohne halbtransparente Striche bleibt das PDF
/// unverändert.
fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
use lopdf::{Dictionary, Object};
let mut states: Vec<(String, f32)> = notebook
.pages
@@ -305,7 +277,7 @@ fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<
states.sort_by(|a, b| a.0.cmp(&b.0));
states.dedup_by(|a, b| a.0 == b.0);
let mut doc = printpdf::lopdf::Document::load_mem(&bytes)?;
let mut doc = lopdf::Document::load_mem(&bytes)?;
let page_ids: Vec<_> = doc.get_pages().into_values().collect();
for page_id in page_ids {
let resources = dict_object_id(&mut doc, page_id, b"Resources")?;
@@ -328,11 +300,11 @@ fn add_alpha_states(bytes: Vec<u8>, notebook: &Notebook) -> Result<Vec<u8>, Box<
/// Kennung des Wörterbuchs unter `key` im Objekt `owner`; ein eingebettetes oder fehlendes
/// Wörterbuch wird dafür zu einem eigenen Objekt.
fn dict_object_id(
doc: &mut printpdf::lopdf::Document,
owner: printpdf::lopdf::ObjectId,
doc: &mut lopdf::Document,
owner: lopdf::ObjectId,
key: &[u8],
) -> Result<printpdf::lopdf::ObjectId, Box<dyn Error>> {
use printpdf::lopdf::{Dictionary, Object};
) -> Result<lopdf::ObjectId, Box<dyn Error>> {
use lopdf::{Dictionary, Object};
let entry = doc.get_object(owner)?.as_dict()?.get(key).ok().cloned();
if let Some(Object::Reference(id)) = entry {
@@ -349,20 +321,6 @@ fn dict_object_id(
Ok(id)
}
/// UTF-8-Text als PDF-Textstring in UTF-16BE mit BOM; `None` bei reinem ASCII oder
/// ungültigem UTF-8.
fn utf16be_text(raw: &[u8]) -> Option<Vec<u8>> {
let text = std::str::from_utf8(raw).ok()?;
if text.is_ascii() {
return None;
}
let mut out = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
out.extend_from_slice(&unit.to_be_bytes());
}
Some(out)
}
/// Unterlagen für alle Seiten mit importierter PDF-Seite.
fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
notebook
@@ -387,7 +345,7 @@ fn pdf_underlays(notebook: &Notebook) -> Vec<crate::pdf_merge::Underlay> {
.collect()
}
/// Generiert das `printpdf::PdfDocumentReference` für alle Seiten des Notizbuchs.
/// Generiert das `crate::pdf_writer::PdfDocumentReference` für alle Seiten des Notizbuchs.
/// Importierte PDF-Seiten sind darin noch nicht enthalten; die legt
/// [`export_notebook_to_bytes`] anschließend als Vektorgrafik darunter.
pub fn generate_pdf_document(
@@ -447,7 +405,7 @@ fn generate_pdf_document_with(
}
let page_ref = doc.get_page(page_idx);
render_background_layer(&page_ref.get_layer(layer_bg), page, pattern);
let layer = |name: &str, render: &dyn Fn(&printpdf::PdfLayerReference)| {
let layer = |name: &str, render: &dyn Fn(&crate::pdf_writer::PdfLayerReference)| {
let layer = page_ref.add_layer(name);
begin_page_layer(&layer, page);
render(&layer);
@@ -470,7 +428,7 @@ fn generate_pdf_document_with(
/// Rendert das Vektorraster auf Layer 1 ("Background").
fn render_background_layer(
layer: &printpdf::PdfLayerReference,
layer: &crate::pdf_writer::PdfLayerReference,
page: &Page,
pattern: PatternColor,
) {
@@ -481,7 +439,7 @@ fn render_background_layer(
.color(crate::background::PaperTheme::White)
.map(crate::background::linear_to_srgb);
let grid_color = Color::Rgb(Rgb::new(r, g, b, None));
layer.set_outline_color(grid_color.clone());
layer.set_outline_color(grid_color);
layer.set_outline_thickness(0.4); // Feine 0.15 mm Haarlinie (ca. 0.4 pt)
layer.set_fill_color(grid_color);
@@ -567,7 +525,11 @@ fn render_background_layer(
}
/// Rendert alle Striche einer Seite auf Layer 2 ("Ink").
fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy: ExportStrategy) {
fn render_ink_layer(
layer: &crate::pdf_writer::PdfLayerReference,
page: &Page,
strategy: ExportStrategy,
) {
for stroke in &page.strokes {
if stroke.is_object() {
continue;
@@ -578,9 +540,9 @@ fn render_ink_layer(layer: &printpdf::PdfLayerReference, page: &Page, strategy:
let alpha_state = alpha_state_name(stroke.color[3]);
if let Some(name) = &alpha_state {
layer.save_graphics_state();
layer.add_operation(printpdf::lopdf::content::Operation::new(
layer.add_operation(lopdf::content::Operation::new(
"gs",
vec![printpdf::lopdf::Object::Name(name.as_bytes().to_vec())],
vec![lopdf::Object::Name(name.as_bytes().to_vec())],
));
}
if let Some(ref shape) = stroke.shape {
@@ -648,8 +610,8 @@ fn pdf_image_xobject(
assets: &AssetStore,
asset: &str,
corners: &[glam::Vec2; 4],
) -> Result<printpdf::ImageXObject, Box<dyn Error>> {
use printpdf::{ColorBits, ColorSpace, ImageFilter, ImageXObject, Px};
) -> Result<crate::pdf_writer::ImageXObject, Box<dyn Error>> {
use crate::pdf_writer::{ColorSpace, ImageFilter, ImageXObject, Px};
let bytes = assets.read_bytes(asset)?;
let decoded = image::load_from_memory(&bytes)?;
@@ -665,12 +627,9 @@ fn pdf_image_xobject(
width: Px(decoded.width() as usize),
height: Px(decoded.height() as usize),
color_space,
bits_per_component: ColorBits::Bit8,
interpolate: true,
image_data: bytes,
image_filter: Some(ImageFilter::DCT),
smask: None,
clipping_bbox: None,
});
}
}
@@ -700,7 +659,7 @@ fn pdf_image_xobject(
/// Rendert die eingefügten Bilder einer Seite. Fehlt eine Bilddatei, erscheint an ihrer
/// Stelle eine graue Fläche, damit der Export nicht abbricht.
fn render_image_layer(
layer: &printpdf::PdfLayerReference,
layer: &crate::pdf_writer::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
@@ -725,7 +684,7 @@ fn render_image_layer(
fn embed_text_fonts(
doc: &PdfDocumentReference,
notebook: &Notebook,
) -> Result<HashMap<TextFont, printpdf::IndirectFontRef>, Box<dyn Error>> {
) -> Result<HashMap<TextFont, crate::pdf_writer::IndirectFontRef>, Box<dyn Error>> {
let mut fonts = HashMap::new();
let used = notebook
.pages
@@ -744,9 +703,9 @@ fn embed_text_fonts(
/// Koordinatensystem (eine Einheit = eine Seiteneinheit, y nach oben) gesetzt, das eine
/// Transformationsmatrix auf seine Ecken abbildet – so stimmen Drehung und Skalierung.
fn render_text_layer(
layer: &printpdf::PdfLayerReference,
layer: &crate::pdf_writer::PdfLayerReference,
page: &Page,
fonts: &HashMap<TextFont, printpdf::IndirectFontRef>,
fonts: &HashMap<TextFont, crate::pdf_writer::IndirectFontRef>,
) {
for stroke in &page.strokes {
let (Some(text), Some(corners)) = (&stroke.text, stroke.object_corners()) else {
@@ -763,7 +722,7 @@ fn render_text_layer(
let o = page_to_pdf_pt(origin);
let matrix = [right.x * k, -right.y * k, -down.x * k, down.y * k, o.x, o.y];
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(matrix));
layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(matrix));
layer.set_fill_color(Color::Rgb(Rgb::new(
stroke.color[0],
stroke.color[1],
@@ -776,9 +735,9 @@ fn render_text_layer(
if line.text.trim().is_empty() {
continue;
}
layer.set_text_matrix(printpdf::TextMatrix::Translate(
printpdf::Pt(0.0),
printpdf::Pt(-layout.baseline(i)),
layer.set_text_matrix(crate::pdf_writer::TextMatrix::Translate(
crate::pdf_writer::Pt(0.0),
crate::pdf_writer::Pt(-layout.baseline(i)),
));
// Glyphen samt Unterschneidung wie auf dem Bildschirm (Abstände in 1/1000 Em)
let face = text.font.face();
@@ -796,45 +755,31 @@ fn render_text_layer(
}
/// Graue 1×1-Fläche für fehlende Bilder.
fn placeholder_xobject() -> printpdf::ImageXObject {
fn placeholder_xobject() -> crate::pdf_writer::ImageXObject {
rgb_xobject(1, 1, vec![200, 200, 200])
}
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> printpdf::ImageXObject {
printpdf::ImageXObject {
width: printpdf::Px(width as usize),
height: printpdf::Px(height as usize),
color_space: printpdf::ColorSpace::Rgb,
bits_per_component: printpdf::ColorBits::Bit8,
fn rgb_xobject(width: u32, height: u32, rgb: Vec<u8>) -> crate::pdf_writer::ImageXObject {
crate::pdf_writer::ImageXObject {
width: crate::pdf_writer::Px(width as usize),
height: crate::pdf_writer::Px(height as usize),
color_space: crate::pdf_writer::ColorSpace::Rgb,
interpolate: true,
image_data: rgb,
image_filter: None,
smask: None,
clipping_bbox: None,
}
}
/// Zeichnet ein Bild so, dass es die Ecken oben links, oben rechts, unten rechts,
/// unten links ausfüllt.
fn draw_image_xobject(
layer: &printpdf::PdfLayerReference,
xobject: printpdf::ImageXObject,
layer: &crate::pdf_writer::PdfLayerReference,
xobject: crate::pdf_writer::ImageXObject,
corners: &[glam::Vec2; 4],
) {
let (width, height) = (xobject.width.0 as f32, xobject.height.0 as f32);
layer.save_graphics_state();
layer.set_ctm(printpdf::CurTransMat::Raw(image_matrix(corners)));
// Bei 72 dpi ist ein Pixel ein Punkt; die Skalierung bildet das Bild auf das
// Einheitsquadrat ab, die Matrix oben auf die Bildecken
printpdf::Image::from(xobject).add_to_layer(
layer.clone(),
printpdf::ImageTransform {
dpi: Some(72.0),
scale_x: Some(1.0 / width),
scale_y: Some(1.0 / height),
..Default::default()
},
);
layer.set_ctm(crate::pdf_writer::CurTransMat::Raw(image_matrix(corners)));
layer.add_image(xobject);
layer.restore_graphics_state();
}
@@ -844,7 +789,7 @@ const PDF_RASTER_DPI: f32 = 200.0;
/// Bettet die importierte PDF-Seite als gerendertes Bild ein (Ausweichlösung).
fn render_pdf_raster(
layer: &printpdf::PdfLayerReference,
layer: &crate::pdf_writer::PdfLayerReference,
page: &Page,
assets: Option<&AssetStore>,
) {
@@ -882,19 +827,19 @@ fn render_pdf_raster(
/// Rendert eine mathematisch bereinigte Form (Gerade, Kreis, Rechteck, Koordinatensystem)
/// als verlustfreien Vektorpfad im PDF.
fn render_recognized_shape(
layer: &printpdf::PdfLayerReference,
layer: &crate::pdf_writer::PdfLayerReference,
shape: &crate::geometry::RecognizedShape,
color: [f32; 4],
) {
use crate::geometry::RecognizedShape;
let stroke_color = Color::Rgb(Rgb::new(color[0], color[1], color[2], None));
layer.set_outline_color(stroke_color.clone());
layer.set_outline_color(stroke_color);
layer.set_outline_thickness(1.5); // ca. 0.5 mm Standard-Liniendicke
// Wie auf dem Bildschirm: spitze Ecken und stumpfe Enden (der Grafikzustand kann noch
// runde Verbindungen von einem vorherigen Freihandstrich enthalten)
layer.set_line_cap_style(printpdf::LineCapStyle::Butt);
layer.set_line_join_style(printpdf::LineJoinStyle::Miter);
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Butt);
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Miter);
match shape {
RecognizedShape::Line { start, end } => {
@@ -1178,7 +1123,7 @@ fn render_recognized_shape(
}
/// Strategie A: Gefülltes Polygon aus den Triangle-Strip-Hüllpunkten.
fn render_stroke_polygon(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
fn render_stroke_polygon(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) {
if stroke.vertices.is_empty() {
return;
}
@@ -1266,8 +1211,8 @@ fn exact_outlines() -> bool {
*EXACT.get_or_init(|| std::env::var_os("INK_NOTES_EXACT_OUTLINES").is_some_and(|v| v != "0"))
}
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via printpdf::Line.
fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
/// Strategie B: Kubische Bézier-Kurven aus den Abtastpunkten via crate::pdf_writer::Line.
fn render_stroke_bezier(layer: &crate::pdf_writer::PdfLayerReference, stroke: &Stroke) {
if stroke.points.is_empty() {
return;
}
@@ -1279,8 +1224,8 @@ fn render_stroke_bezier(layer: &printpdf::PdfLayerReference, stroke: &Stroke) {
None,
));
layer.set_outline_color(outline_color);
layer.set_line_cap_style(printpdf::LineCapStyle::Round);
layer.set_line_join_style(printpdf::LineJoinStyle::Round);
layer.set_line_cap_style(crate::pdf_writer::LineCapStyle::Round);
layer.set_line_join_style(crate::pdf_writer::LineJoinStyle::Round);
// Durchschnittliche Linienstärke aus dem Druck berechnen
let avg_pressure =
@@ -1753,10 +1698,10 @@ mod tests {
PatternColor::default(),
)
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let doc = lopdf::Document::load_mem(&bytes).unwrap();
assert_eq!(doc.get_pages().len(), 2);
for page_id in doc.get_pages().into_values() {
let content = doc.get_page_content(page_id).unwrap();
let content = doc.get_page_content(page_id);
assert!(
String::from_utf8_lossy(&content).starts_with("q "),
"Originalseite fehlt als Unterlage"
@@ -1864,7 +1809,7 @@ mod tests {
PatternColor::default(),
)
.unwrap();
let doc = printpdf::lopdf::Document::load_mem(&bytes).unwrap();
let doc = lopdf::Document::load_mem(&bytes).unwrap();
let catalog = doc.catalog().unwrap();
assert!(catalog.has(b"Outlines"), "Lesezeichen fehlen");
@@ -1875,17 +1820,15 @@ mod tests {
.filter_map(|o| o.as_dict().ok())
.filter(|d| d.has(b"Parent"))
.filter_map(|d| match d.get(b"Title") {
Ok(printpdf::lopdf::Object::String(raw, _)) => {
Some(if raw.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = raw[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
} else {
String::from_utf8(raw.clone()).unwrap()
})
}
Ok(lopdf::Object::String(raw, _)) => Some(if raw.starts_with(&[0xFE, 0xFF]) {
let units: Vec<u16> = raw[2..]
.chunks(2)
.map(|c| u16::from_be_bytes([c[0], c[1]]))
.collect();
String::from_utf16(&units).unwrap()
} else {
String::from_utf8(raw.clone()).unwrap()
}),
_ => None,
})
.collect();
+1
View File
@@ -21,6 +21,7 @@ pub mod page_export;
pub mod paths;
pub mod pdf_doc;
pub mod pdf_merge;
pub mod pdf_writer;
pub mod platform;
pub mod rnote;
pub mod ruler;
+3 -3
View File
@@ -1,13 +1,13 @@
//! Legt beim PDF-Export die Originalseiten importierter PDFs als Vektorgrafik unter die
//! Notizen (Text bleibt scharf und auswählbar).
//!
//! Vorgehen: Das von printpdf erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
//! Vorgehen: Das vom Export erzeugte PDF wird mit `lopdf` geöffnet. Jede benötigte Seite
//! des Quell-PDFs wird als Form-XObject (Inhalt + Ressourcen der Seite) übernommen und am
//! Anfang der Zielseite gezeichnet, also unter Hintergrund, Bildern und Tinte.
use std::collections::{BTreeMap, HashMap};
use printpdf::lopdf::{Dictionary, Document, Object, ObjectId, Stream};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
use crate::t;
@@ -361,7 +361,7 @@ mod tests {
2,
"Quellseiten dürfen nicht als eigene Seiten auftauchen"
);
let first = doc.get_page_content(pages[0]).unwrap();
let first = doc.get_page_content(pages[0]);
let text = String::from_utf8_lossy(&first);
assert!(
text.starts_with("q "),
+879
View File
@@ -0,0 +1,879 @@
//! Small PDF writer on top of `lopdf` for the vector export.
//!
//! Replaces printpdf 0.7, which still depended on lopdf 0.31. The interface follows the part
//! of printpdf that [`crate::export`] used: a document with pages, each page with named
//! layers (optional content groups, shown as layers in PDF viewers) that collect drawing
//! operations, embedded TrueType fonts for real, searchable text, images and bookmarks.
use std::cell::RefCell;
use std::collections::{BTreeMap, BTreeSet};
use std::error::Error;
use std::rc::Rc;
use ab_glyph::Font as _;
use lopdf::content::{Content, Operation};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream, StringFormat};
/// Length in millimetres.
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Mm(pub f32);
/// Length in PDF points (1/72 inch).
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Pt(pub f32);
impl From<Mm> for Pt {
fn from(mm: Mm) -> Self {
Pt(mm.0 * 72.0 / 25.4)
}
}
/// Size in pixels.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Px(pub usize);
/// Point on the page (origin bottom left).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Point {
pub x: Pt,
pub y: Pt,
}
impl Point {
pub fn new(x: Mm, y: Mm) -> Self {
Self {
x: x.into(),
y: y.into(),
}
}
}
/// Open or closed polyline; a point marked `true` followed by another marked `true` starts a
/// cubic Bézier segment (the two are its control points, the next one its end point).
#[derive(Debug, Clone)]
pub struct Line {
pub points: Vec<(Point, bool)>,
pub is_closed: bool,
}
/// How a polygon is painted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaintMode {
Fill,
Stroke,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WindingOrder {
NonZero,
}
/// Closed shape of one or more rings (same point marking as [`Line`]).
#[derive(Debug, Clone)]
pub struct Polygon {
pub rings: Vec<Vec<(Point, bool)>>,
pub mode: PaintMode,
pub winding_order: WindingOrder,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rgb {
pub r: f32,
pub g: f32,
pub b: f32,
}
impl Rgb {
/// The last argument (ICC profile in printpdf) is ignored.
pub fn new(r: f32, g: f32, b: f32, _icc: Option<()>) -> Self {
Self { r, g, b }
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Color {
Rgb(Rgb),
}
/// Change of the coordinate system (`cm`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CurTransMat {
Translate(Pt, Pt),
Raw([f32; 6]),
}
/// Text position within a text section (`Tm`).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TextMatrix {
Translate(Pt, Pt),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineCapStyle {
Butt,
Round,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineJoinStyle {
Miter,
Round,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorSpace {
Rgb,
Greyscale,
}
/// Image data already compressed in this format (embedded unchanged).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageFilter {
/// JPEG
DCT,
}
/// Image with 8 bits per component.
#[derive(Debug, Clone)]
pub struct ImageXObject {
pub width: Px,
pub height: Px,
pub color_space: ColorSpace,
pub interpolate: bool,
/// Raw samples, or the compressed data for `image_filter`
pub image_data: Vec<u8>,
pub image_filter: Option<ImageFilter>,
}
/// Font embedded with [`PdfDocumentReference::add_external_font`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndirectFontRef {
index: usize,
}
impl IndirectFontRef {
fn resource_name(&self) -> String {
format!("F{}", self.index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdfPageIndex(pub usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PdfLayerIndex(pub usize);
struct LayerData {
name: String,
operations: Vec<Operation>,
/// Font of the current text section (to record the glyphs it uses)
current_font: Option<usize>,
}
struct PageData {
width: Pt,
height: Pt,
layers: Vec<LayerData>,
images: Vec<ImageXObject>,
}
struct FontData {
face: ab_glyph::FontRef<'static>,
bytes: &'static [u8],
used_glyphs: BTreeSet<u16>,
}
struct DocumentData {
title: String,
pages: Vec<PageData>,
fonts: Vec<FontData>,
bookmarks: Vec<(String, usize)>,
}
/// Entry point like printpdf's `PdfDocument::new`.
pub struct PdfDocument;
impl PdfDocument {
/// New document with a first page of the given size and its first layer.
#[allow(clippy::new_ret_no_self)]
pub fn new(
title: &str,
width: Mm,
height: Mm,
layer: &str,
) -> (PdfDocumentReference, PdfPageIndex, PdfLayerIndex) {
let doc = PdfDocumentReference(Rc::new(RefCell::new(DocumentData {
title: title.to_string(),
pages: Vec::new(),
fonts: Vec::new(),
bookmarks: Vec::new(),
})));
let (page, layer) = doc.add_page(width, height, layer);
(doc, page, layer)
}
}
/// The document being built; cheap to clone, all clones share it.
#[derive(Clone)]
pub struct PdfDocumentReference(Rc<RefCell<DocumentData>>);
impl PdfDocumentReference {
/// Appends a page with its first layer.
pub fn add_page(&self, width: Mm, height: Mm, layer: &str) -> (PdfPageIndex, PdfLayerIndex) {
let mut doc = self.0.borrow_mut();
doc.pages.push(PageData {
width: width.into(),
height: height.into(),
layers: vec![LayerData {
name: layer.to_string(),
operations: Vec::new(),
current_font: None,
}],
images: Vec::new(),
});
(PdfPageIndex(doc.pages.len() - 1), PdfLayerIndex(0))
}
/// Bookmark (outline entry) pointing at a page.
pub fn add_bookmark(&self, title: String, page: PdfPageIndex) {
self.0.borrow_mut().bookmarks.push((title, page.0));
}
pub fn get_page(&self, page: PdfPageIndex) -> PdfPageReference {
PdfPageReference {
doc: self.clone(),
page: page.0,
}
}
/// Embeds a TrueType/OpenType font with TrueType outlines (Type0 font, Identity-H).
pub fn add_external_font(
&self,
bytes: &'static [u8],
) -> Result<IndirectFontRef, Box<dyn Error>> {
let face = ab_glyph::FontRef::try_from_slice(bytes)?;
let mut doc = self.0.borrow_mut();
doc.fonts.push(FontData {
face,
bytes,
used_glyphs: BTreeSet::new(),
});
Ok(IndirectFontRef {
index: doc.fonts.len() - 1,
})
}
/// Writes the document.
pub fn save_to_bytes(self) -> Result<Vec<u8>, Box<dyn Error>> {
let data = self.0.borrow();
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
// Fonts: shared by all pages
let mut font_resources = Dictionary::new();
for (index, font) in data.fonts.iter().enumerate() {
let id = write_font(&mut doc, font, index)?;
font_resources.set(format!("F{index}"), Object::Reference(id));
}
// One optional content group per layer name, in the order of appearance
let mut layer_names: Vec<&str> = Vec::new();
for layer in data.pages.iter().flat_map(|p| &p.layers) {
if !layer_names.contains(&layer.name.as_str()) {
layer_names.push(&layer.name);
}
}
let ocgs: Vec<ObjectId> = layer_names
.iter()
.map(|name| {
let mut ocg = Dictionary::new();
ocg.set("Type", Object::Name(b"OCG".to_vec()));
ocg.set("Name", text_string(name));
doc.add_object(ocg)
})
.collect();
let mut page_ids = Vec::new();
for page in &data.pages {
let mut operations = Vec::new();
let mut properties = Dictionary::new();
for layer in &page.layers {
let index = layer_names
.iter()
.position(|n| *n == layer.name)
.unwrap_or(0);
let tag = format!("MC{index}");
properties.set(tag.clone(), Object::Reference(ocgs[index]));
operations.push(Operation::new(
"BDC",
vec![Object::Name(b"OC".to_vec()), Object::Name(tag.into_bytes())],
));
operations.push(Operation::new("q", vec![]));
operations.extend(layer.operations.iter().cloned());
operations.push(Operation::new("Q", vec![]));
operations.push(Operation::new("EMC", vec![]));
}
let mut contents = Stream::new(Dictionary::new(), Content { operations }.encode()?);
contents.compress()?;
let contents_id = doc.add_object(contents);
let mut xobjects = Dictionary::new();
for (i, image) in page.images.iter().enumerate() {
let id = doc.add_object(image_stream(image)?);
xobjects.set(format!("X{i}"), Object::Reference(id));
}
let mut resources = Dictionary::new();
resources.set("Font", Object::Dictionary(font_resources.clone()));
resources.set("XObject", Object::Dictionary(xobjects));
resources.set("Properties", Object::Dictionary(properties));
let resources_id = doc.add_object(resources);
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"Page".to_vec()));
dict.set("Parent", Object::Reference(pages_id));
dict.set(
"MediaBox",
Object::Array(vec![
0.into(),
0.into(),
page.width.0.into(),
page.height.0.into(),
]),
);
dict.set("Contents", Object::Reference(contents_id));
dict.set("Resources", Object::Reference(resources_id));
page_ids.push(doc.add_object(dict));
}
let mut pages = Dictionary::new();
pages.set("Type", Object::Name(b"Pages".to_vec()));
pages.set(
"Kids",
Object::Array(page_ids.iter().map(|id| Object::Reference(*id)).collect()),
);
pages.set("Count", Object::Integer(page_ids.len() as i64));
doc.objects.insert(pages_id, Object::Dictionary(pages));
let mut catalog = Dictionary::new();
catalog.set("Type", Object::Name(b"Catalog".to_vec()));
catalog.set("Pages", Object::Reference(pages_id));
let ocg_refs: Vec<Object> = ocgs.iter().map(|id| Object::Reference(*id)).collect();
let mut default_config = Dictionary::new();
default_config.set("Order", Object::Array(ocg_refs.clone()));
let mut oc_properties = Dictionary::new();
oc_properties.set("OCGs", Object::Array(ocg_refs));
oc_properties.set("D", Object::Dictionary(default_config));
catalog.set("OCProperties", Object::Dictionary(oc_properties));
if !data.bookmarks.is_empty() {
let outlines = write_outlines(&mut doc, &data.bookmarks, &page_ids);
catalog.set("Outlines", Object::Reference(outlines));
catalog.set("PageMode", Object::Name(b"UseOutlines".to_vec()));
}
let catalog_id = doc.add_object(catalog);
let mut info = Dictionary::new();
info.set("Title", text_string(&data.title));
info.set("Producer", text_string("ink-notes"));
let info_id = doc.add_object(info);
doc.trailer.set("Root", Object::Reference(catalog_id));
doc.trailer.set("Info", Object::Reference(info_id));
let mut out = Vec::new();
doc.save_to(&mut out)?;
Ok(out)
}
}
/// PDF text string: literal for ASCII, otherwise UTF-16BE with byte order mark.
pub fn text_string(text: &str) -> Object {
if text.is_ascii() {
return Object::String(text.as_bytes().to_vec(), StringFormat::Literal);
}
let mut bytes = vec![0xFE, 0xFF];
for unit in text.encode_utf16() {
bytes.extend_from_slice(&unit.to_be_bytes());
}
Object::String(bytes, StringFormat::Hexadecimal)
}
/// Outline tree with one entry per bookmark; returns the outline root.
fn write_outlines(
doc: &mut Document,
bookmarks: &[(String, usize)],
pages: &[ObjectId],
) -> ObjectId {
let root_id = doc.new_object_id();
let ids: Vec<ObjectId> = bookmarks.iter().map(|_| doc.new_object_id()).collect();
for (i, (title, page)) in bookmarks.iter().enumerate() {
let mut item = Dictionary::new();
item.set("Title", text_string(title));
item.set("Parent", Object::Reference(root_id));
if i > 0 {
item.set("Prev", Object::Reference(ids[i - 1]));
}
if let Some(next) = ids.get(i + 1) {
item.set("Next", Object::Reference(*next));
}
if let Some(page_id) = pages.get(*page) {
item.set(
"Dest",
Object::Array(vec![
Object::Reference(*page_id),
Object::Name(b"Fit".to_vec()),
]),
);
}
doc.objects.insert(ids[i], Object::Dictionary(item));
}
let mut root = Dictionary::new();
root.set("Type", Object::Name(b"Outlines".to_vec()));
root.set("First", Object::Reference(ids[0]));
root.set("Last", Object::Reference(ids[ids.len() - 1]));
root.set("Count", Object::Integer(ids.len() as i64));
doc.objects.insert(root_id, Object::Dictionary(root));
root_id
}
/// Image XObject stream.
fn image_stream(image: &ImageXObject) -> Result<Stream, Box<dyn Error>> {
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"XObject".to_vec()));
dict.set("Subtype", Object::Name(b"Image".to_vec()));
dict.set("Width", Object::Integer(image.width.0 as i64));
dict.set("Height", Object::Integer(image.height.0 as i64));
let color_space: &[u8] = match image.color_space {
ColorSpace::Rgb => b"DeviceRGB",
ColorSpace::Greyscale => b"DeviceGray",
};
dict.set("ColorSpace", Object::Name(color_space.to_vec()));
dict.set("BitsPerComponent", Object::Integer(8));
dict.set("Interpolate", Object::Boolean(image.interpolate));
match image.image_filter {
Some(ImageFilter::DCT) => {
dict.set("Filter", Object::Name(b"DCTDecode".to_vec()));
Ok(Stream::new(dict, image.image_data.clone()).with_compression(false))
}
None => {
let mut stream = Stream::new(dict, image.image_data.clone());
stream.compress()?;
Ok(stream)
}
}
}
/// Embeds a font as Type0 font with a CIDFontType2 descendant (glyph ids as character codes),
/// widths and a ToUnicode map of the glyphs used, so that the text can be searched and copied.
fn write_font(
doc: &mut Document,
font: &FontData,
index: usize,
) -> Result<ObjectId, Box<dyn Error>> {
let face = &font.face;
let units_per_em = face.units_per_em().unwrap_or(1000.0);
let scale = 1000.0 / units_per_em;
let name = format!("InkNotesFont{index}");
let mut file = Stream::new(
Dictionary::from_iter(vec![("Length1", Object::Integer(font.bytes.len() as i64))]),
font.bytes.to_vec(),
);
file.compress()?;
let file_id = doc.add_object(file);
let ascent = (face.ascent_unscaled() * scale).round() as i64;
let descent = (face.descent_unscaled() * scale).round() as i64;
let mut descriptor = Dictionary::new();
descriptor.set("Type", Object::Name(b"FontDescriptor".to_vec()));
descriptor.set("FontName", Object::Name(name.clone().into_bytes()));
descriptor.set("Flags", Object::Integer(32));
descriptor.set(
"FontBBox",
Object::Array(vec![0.into(), descent.into(), 1000.into(), ascent.into()]),
);
descriptor.set("ItalicAngle", Object::Integer(0));
descriptor.set("Ascent", Object::Integer(ascent));
descriptor.set("Descent", Object::Integer(descent));
descriptor.set("CapHeight", Object::Integer(ascent));
descriptor.set("StemV", Object::Integer(80));
descriptor.set("FontFile2", Object::Reference(file_id));
let descriptor_id = doc.add_object(descriptor);
// Widths of the glyphs used: gid [width]
let mut widths = Vec::new();
for &gid in &font.used_glyphs {
let width = face.h_advance_unscaled(ab_glyph::GlyphId(gid)) * scale;
widths.push(Object::Integer(gid as i64));
widths.push(Object::Array(vec![Object::Integer(width.round() as i64)]));
}
let mut system_info = Dictionary::new();
system_info.set("Registry", Object::string_literal("Adobe"));
system_info.set("Ordering", Object::string_literal("Identity"));
system_info.set("Supplement", Object::Integer(0));
let mut descendant = Dictionary::new();
descendant.set("Type", Object::Name(b"Font".to_vec()));
descendant.set("Subtype", Object::Name(b"CIDFontType2".to_vec()));
descendant.set("BaseFont", Object::Name(name.clone().into_bytes()));
descendant.set("CIDSystemInfo", Object::Dictionary(system_info));
descendant.set("FontDescriptor", Object::Reference(descriptor_id));
descendant.set("CIDToGIDMap", Object::Name(b"Identity".to_vec()));
descendant.set("DW", Object::Integer(1000));
descendant.set("W", Object::Array(widths));
let descendant_id = doc.add_object(descendant);
let mut to_unicode = Stream::new(Dictionary::new(), to_unicode_cmap(font).into_bytes());
to_unicode.compress()?;
let to_unicode_id = doc.add_object(to_unicode);
let mut dict = Dictionary::new();
dict.set("Type", Object::Name(b"Font".to_vec()));
dict.set("Subtype", Object::Name(b"Type0".to_vec()));
dict.set("BaseFont", Object::Name(name.into_bytes()));
dict.set("Encoding", Object::Name(b"Identity-H".to_vec()));
dict.set(
"DescendantFonts",
Object::Array(vec![Object::Reference(descendant_id)]),
);
dict.set("ToUnicode", Object::Reference(to_unicode_id));
Ok(doc.add_object(dict))
}
/// CMap from the glyph ids used to their characters (UTF-16BE).
fn to_unicode_cmap(font: &FontData) -> String {
let mut chars: BTreeMap<u16, char> = BTreeMap::new();
for (gid, c) in font.face.codepoint_ids() {
if font.used_glyphs.contains(&gid.0) {
// Prefer the lowest code point (e.g. a letter over a compatibility form)
let entry = chars.entry(gid.0).or_insert(c);
if c < *entry {
*entry = c;
}
}
}
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
let entries: Vec<(u16, char)> = chars.into_iter().collect();
for block in entries.chunks(100) {
cmap.push_str(&format!("{} beginbfchar\n", block.len()));
for (gid, c) in block {
let mut units = [0u16; 2];
let hex: String = c
.encode_utf16(&mut units)
.iter()
.map(|u| format!("{u:04X}"))
.collect();
cmap.push_str(&format!("<{gid:04X}> <{hex}>\n"));
}
cmap.push_str("endbfchar\n");
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
cmap
}
pub struct PdfPageReference {
doc: PdfDocumentReference,
page: usize,
}
impl PdfPageReference {
pub fn get_layer(&self, layer: PdfLayerIndex) -> PdfLayerReference {
PdfLayerReference {
doc: self.doc.clone(),
page: self.page,
layer: layer.0,
}
}
/// Adds a layer drawn above the existing ones.
pub fn add_layer(&self, name: &str) -> PdfLayerReference {
let mut doc = self.doc.0.borrow_mut();
let layers = &mut doc.pages[self.page].layers;
layers.push(LayerData {
name: name.to_string(),
operations: Vec::new(),
current_font: None,
});
PdfLayerReference {
doc: self.doc.clone(),
page: self.page,
layer: layers.len() - 1,
}
}
}
/// A layer of a page; all drawing goes through it.
#[derive(Clone)]
pub struct PdfLayerReference {
doc: PdfDocumentReference,
page: usize,
layer: usize,
}
impl PdfLayerReference {
fn with_layer<T>(&self, f: impl FnOnce(&mut LayerData) -> T) -> T {
let mut doc = self.doc.0.borrow_mut();
f(&mut doc.pages[self.page].layers[self.layer])
}
fn push(&self, operator: &str, operands: Vec<Object>) {
self.add_operation(Operation::new(operator, operands));
}
pub fn add_operation(&self, operation: Operation) {
self.with_layer(|layer| layer.operations.push(operation));
}
pub fn save_graphics_state(&self) {
self.push("q", vec![]);
}
pub fn restore_graphics_state(&self) {
self.push("Q", vec![]);
}
pub fn set_ctm(&self, matrix: CurTransMat) {
let m = match matrix {
CurTransMat::Translate(x, y) => [1.0, 0.0, 0.0, 1.0, x.0, y.0],
CurTransMat::Raw(m) => m,
};
self.push("cm", m.iter().map(|&v| v.into()).collect());
}
pub fn set_fill_color(&self, color: Color) {
let Color::Rgb(c) = color;
self.push("rg", vec![c.r.into(), c.g.into(), c.b.into()]);
}
pub fn set_outline_color(&self, color: Color) {
let Color::Rgb(c) = color;
self.push("RG", vec![c.r.into(), c.g.into(), c.b.into()]);
}
pub fn set_outline_thickness(&self, thickness: f32) {
self.push("w", vec![thickness.into()]);
}
pub fn set_line_cap_style(&self, style: LineCapStyle) {
let value = match style {
LineCapStyle::Butt => 0,
LineCapStyle::Round => 1,
};
self.push("J", vec![Object::Integer(value)]);
}
pub fn set_line_join_style(&self, style: LineJoinStyle) {
let value = match style {
LineJoinStyle::Miter => 0,
LineJoinStyle::Round => 1,
};
self.push("j", vec![Object::Integer(value)]);
}
pub fn add_line(&self, line: Line) {
if line.points.is_empty() {
return;
}
let mut operations = path_operations(&line.points);
operations.push(Operation::new(
if line.is_closed { "s" } else { "S" },
vec![],
));
self.with_layer(|layer| layer.operations.extend(operations));
}
pub fn add_polygon(&self, polygon: Polygon) {
let mut operations: Vec<Operation> = polygon
.rings
.iter()
.filter(|ring| !ring.is_empty())
.flat_map(|ring| path_operations(ring))
.collect();
if operations.is_empty() {
return;
}
operations.push(Operation::new(
match polygon.mode {
PaintMode::Fill => "f",
PaintMode::Stroke => "s",
},
vec![],
));
self.with_layer(|layer| layer.operations.extend(operations));
}
/// Draws `image` into the unit square of the current coordinate system.
pub fn add_image(&self, image: ImageXObject) {
let mut doc = self.doc.0.borrow_mut();
let page = &mut doc.pages[self.page];
page.images.push(image);
let name = format!("X{}", page.images.len() - 1);
page.layers[self.layer]
.operations
.push(Operation::new("Do", vec![Object::Name(name.into_bytes())]));
}
pub fn begin_text_section(&self) {
self.push("BT", vec![]);
}
pub fn end_text_section(&self) {
self.push("ET", vec![]);
self.with_layer(|layer| layer.current_font = None);
}
pub fn set_font(&self, font: &IndirectFontRef, size: f32) {
self.push(
"Tf",
vec![Object::Name(font.resource_name().into_bytes()), size.into()],
);
self.with_layer(|layer| layer.current_font = Some(font.index));
}
pub fn set_text_matrix(&self, matrix: TextMatrix) {
let TextMatrix::Translate(x, y) = matrix;
self.push(
"Tm",
vec![
1.into(),
0.into(),
0.into(),
1.into(),
x.0.into(),
y.0.into(),
],
);
}
/// Shows glyphs of the current font; each comes with a shift before it in 1/1000 em
/// (positive moves left, as in `TJ`).
pub fn write_positioned_codepoints(&self, glyphs: impl IntoIterator<Item = (i64, u16)>) {
let mut list = Vec::new();
let mut used = Vec::new();
for (shift, gid) in glyphs {
if shift != 0 {
list.push(Object::Integer(shift));
}
list.push(Object::String(
gid.to_be_bytes().to_vec(),
StringFormat::Hexadecimal,
));
used.push(gid);
}
let mut doc = self.doc.0.borrow_mut();
let layer = &mut doc.pages[self.page].layers[self.layer];
layer
.operations
.push(Operation::new("TJ", vec![Object::Array(list)]));
if let Some(font) = layer.current_font {
doc.fonts[font].used_glyphs.extend(used);
}
}
}
/// `m`, `l` and `c` operations for a point list (see [`Line`] for the Bézier marking).
fn path_operations(points: &[(Point, bool)]) -> Vec<Operation> {
let xy = |p: &Point| -> [Object; 2] { [p.x.0.into(), p.y.0.into()] };
let mut operations = vec![Operation::new("m", xy(&points[0].0).to_vec())];
let mut current = 1;
while current < points.len() {
let (previous, point) = (&points[current - 1], &points[current]);
if previous.1 && point.1 {
if let (Some(c2), Some(end)) = (points.get(current + 1), points.get(current + 2)) {
let operands = [xy(&point.0), xy(&c2.0), xy(&end.0)].concat();
operations.push(Operation::new("c", operands));
current += 3;
continue;
}
}
operations.push(Operation::new("l", xy(&point.0).to_vec()));
current += 1;
}
operations
}
#[cfg(test)]
mod tests {
use super::*;
fn line(points: &[(f32, f32, bool)]) -> Vec<(Point, bool)> {
points
.iter()
.map(|&(x, y, b)| (Point::new(Mm(x), Mm(y)), b))
.collect()
}
#[test]
fn test_path_operations_lines_and_curves() {
let ops = path_operations(&line(&[
(0.0, 0.0, true),
(1.0, 0.0, true),
(2.0, 0.0, false),
(3.0, 0.0, false),
(4.0, 0.0, false),
]));
let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect();
assert_eq!(names, ["m", "c", "l"]);
assert_eq!(ops[1].operands.len(), 6);
}
#[test]
fn test_document_with_text_image_and_bookmarks() {
let (doc, page, layer) = PdfDocument::new("Test", Mm(210.0), Mm(297.0), "Background");
let font = doc
.add_external_font(crate::text::TextFont::Sans.bytes())
.unwrap();
doc.add_bookmark("Übung – 1".to_string(), page);
let background = doc.get_page(page).get_layer(layer);
background.add_polygon(Polygon {
rings: vec![line(&[
(0.0, 0.0, false),
(10.0, 0.0, false),
(10.0, 10.0, false),
])],
mode: PaintMode::Fill,
winding_order: WindingOrder::NonZero,
});
let text = doc.get_page(page).add_layer("Text");
let face = crate::text::TextFont::Sans.face();
let gids: Vec<u16> = "Hä".chars().map(|c| face.glyph_id(c).0).collect();
text.begin_text_section();
text.set_font(&font, 12.0);
text.write_positioned_codepoints(gids.iter().map(|&g| (0, g)));
text.end_text_section();
text.add_image(ImageXObject {
width: Px(1),
height: Px(1),
color_space: ColorSpace::Rgb,
interpolate: true,
image_data: vec![255, 0, 0],
image_filter: None,
});
let bytes = doc.save_to_bytes().unwrap();
let pdf = Document::load_mem(&bytes).unwrap();
assert_eq!(pdf.get_pages().len(), 1);
let page_id = *pdf.get_pages().values().next().unwrap();
let content = pdf.get_page_content(page_id);
let ops = Content::decode(&content).unwrap().operations;
let names: Vec<&str> = ops.iter().map(|o| o.operator.as_str()).collect();
for expected in ["BDC", "f", "BT", "Tf", "TJ", "ET", "Do", "EMC"] {
assert!(names.contains(&expected), "{expected} missing in {names:?}");
}
// The ToUnicode map knows both characters
let fonts = pdf.get_page_fonts(page_id).unwrap();
let font = fonts.values().next().unwrap();
let to_unicode = pdf
.get_object(font.get(b"ToUnicode").unwrap().as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap()
.decompressed_content()
.unwrap();
let cmap = String::from_utf8(to_unicode).unwrap();
assert!(cmap.contains("<0048>"), "{cmap}");
assert!(cmap.contains("<00E4>"), "{cmap}");
}
}