mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
fix(print): M10 — Windows ya no cambia las letras de las fuentes TrueType incrustadas (#3132) (#4098)
* fix(print): M10 — Windows ya no cambia las letras de las fuentes TrueType incrustadas (#3132) Un subconjunto TrueType incrustado numera sus glifos de nuevo (en el subconjunto de Arial de ReportLab el espacio es el glifo 11; en la Arial instalada, el 3). Al imprimir en Windows, Windows.Data.Pdf → Direct2D → XPS conservaba esos números de glifo pero los dibujaba con la fuente instalada del mismo nombre: «Revisión» salía como «Wj{nxn» y la «z» en negrita como «Ä», tanto en las colas Brother como en la impresora Adobe PDF. La copia que va a la cola de Windows renombra ahora cada programa TrueType/OpenType incrustado como PdfCraftPrint<n>, en su tabla name y en el PDF (/BaseFont, /FontName; el subconjunto conserva su etiqueta), para que ninguna fuente instalada responda a ese nombre. Glifos, codificaciones y /ToUnicode no cambian. CUPS y Guardar como PDF reciben el PDF tal cual. Tests: extremo a extremo con impose (la página se renderiza idéntica antes y después, y el test falla sin el arreglo), fuentes que comparten programa, Type 0, reescritura por plataforma de la tabla name y programas dañados sin pánico. Falta comprobarlo en una impresora física con Windows. Fixes #3132 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(print): M10 — solo se renombran en el PDF las fuentes cuyo programa se reescribe (#3132) Si PdfCraft no puede leer o reescribir un programa incrustado (no es un sfnt, está dañado o pasa de 64 MiB), ahora conserva también su /BaseFont y su /FontName. Un visor que tampoco pueda cargarlo seguirá eligiendo el sustituto por el nombre original («Arial») en vez de por PdfCraftPrint<n>. Si no queda ningún programa reescrito, el PDF va a la cola tal cual. Lo señaló CodeRabbit en el PR #4098. Test nuevo: a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too (falla con el comportamiento anterior). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(print): M10 — el renombrado de fuentes para Windows decodifica contra un presupuesto total (#3132) Cada programa ya tenía un tope de 64 MiB, pero un PDF con muchos programas distintos podía sumar mucho trabajo de descompresión al imprimir. Ahora todo el renombrado comparte un DecodeBudget de 512 MiB, el mecanismo que main acaba de introducir en print. Agotado el presupuesto, los programas que faltan conservan su nombre, igual que uno ilegible. Test nuevo: programs_past_the_shared_decode_budget_keep_their_names (falla si se decodifica sin presupuesto). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(print): M10 — también se renombran los CIDFont escritos dentro de su fuente Type 0 (#3132) Si /DescendantFonts lleva el CIDFont como diccionario en línea (en el propio Type 0 o en un objeto array aparte), el recorrido no llegaba a él. Cuando compartía programa con otra fuente, el programa se renombraba, pero el Type 0, el CIDFont y su descriptor seguían diciendo «Arial», y Windows podía volver a cruzarlos con la Arial instalada. Ahora el Type 0 se trata en el mismo recorrido, sin el segundo que hacía type0_names: un CIDFont en línea apunta su programa, y al renombrar se cambian el Type 0, el CIDFont y su descriptor en línea. Lo señaló CodeRabbit en el PR #4098. Test nuevo: a_cidfont_written_inside_its_type0_font_is_renamed_with_it (falla sin el arreglo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(print): M10 — renamed_table ya no copia cada cadena de la tabla name (#3132) renamed_table copiaba el texto de cada registro antes de comprobar el tamaño del resultado: una tabla name de menos de 1 MB con 65.535 registros apuntando a una misma cadena de 65.535 bytes reservaba unos 4 GiB, y un fallo de reserva cierra el proceso al imprimir en Windows. Ahora la tabla se lee entera sin copiar ningún texto (cada registro guarda el offset y la longitud de origen) y después cada cadena distinta se guarda una sola vez: los registros que compartían cadena la siguen compartiendo y el almacén nunca pasa de lo que alcanzan los offsets de 16 bits. Una tabla que declara más registros de los que caben antes de sus cadenas se rechaza de entrada. Lo encontró RomanLis91 en el PR #4098. Tests nuevos: records_that_share_one_string_keep_sharing_it (falla con el código anterior) y a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
72e0bec32a
commit
6914607f17
10 files changed
+928
-3
No files matched your search
Generated
+1
@@ -4660,6 +4660,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
|
||||
@@ -330,6 +330,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
|
||||
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
|
||||
@@ -17,3 +17,5 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-render = { workspace = true }
|
||||
# Reads the renamed programs back with a parser that isn't ours.
|
||||
skrifa = "0.44"
|
||||
@@ -70,6 +70,14 @@ orientation, zero added margins and hardware-edge clipping. Long-edge and short-
|
||||
use the corresponding Windows binding edges. Random, exclusively created Windows job files
|
||||
are removed on success and failure. Other targets offer the print-ready PDF instead.
|
||||
|
||||
Before a job reaches the Windows spooler, every embedded TrueType or OpenType program is
|
||||
renamed `PdfCraftPrint<n>` in its `name` table and in the PDF (`/BaseFont`, `/FontName`; a
|
||||
subset keeps its tag). Windows otherwise draws a subset's renumbered glyphs with the installed
|
||||
font of the same name, so ReportLab's Arial subsets printed as shifted letters (#3132). Glyphs,
|
||||
encodings and `/ToUnicode` are unchanged, and CUPS and Save as PDF get the PDF as it was. A
|
||||
program PdfCraft can't read and rewrite keeps its old name in the PDF too, so a viewer that
|
||||
can't load it either still picks its substitute by that name.
|
||||
|
||||
The shared dependency requires Rust 1.95. Transport and native Windows regression tests
|
||||
live in craft-libs; local tests retain composition, facade, engine, automation and UI coverage.
|
||||
The desktop UI discovers printers and submits jobs on workers, so neither operation blocks
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Embedded TrueType and OpenType programs renamed before a print-ready PDF goes to the Windows
|
||||
//! spooler, so no installed font answers to their names (#3132).
|
||||
//!
|
||||
//! A subset program numbers its glyphs anew: ReportLab's Arial subsets draw a space with glyph 11
|
||||
//! (Arial Bold: glyph 8), where the installed Arial's space is glyph 3. Windows' print path
|
||||
//! (Windows.Data.Pdf drawing into Direct2D's print control, through craft-print) keeps the
|
||||
//! subset's glyph numbers but draws them with the installed font of the same name, so every letter
|
||||
//! prints as the installed glyph with that number: `Revisión de clientes,` in the bold subset comes
|
||||
//! out as `Wj{nxn s%ij%hqnjsyjx1`, its `z` as `Ä`. Both Brother queues and the Adobe PDF printer
|
||||
//! show it; PdfCraft's renderer, CUPS and Chrome on Windows draw the embedded program and are right.
|
||||
//!
|
||||
//! [`distinct`] renames each program to `PdfCraftPrint<n>`: the identity records of its `name`
|
||||
//! table, `/FontName` of its descriptors and `/BaseFont` of the fonts drawn with it (a subset keeps
|
||||
//! its tag, `AAAAAA+PdfCraftPrint1`). Glyphs, glyph numbers, cmaps, encodings, widths and
|
||||
//! `/ToUnicode` are not touched, so pages draw and extract as before. A program that can't be
|
||||
//! read and rewritten keeps its old name everywhere.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
use crate::PrintError;
|
||||
|
||||
/// Whether [`crate::spool`] renames the programs before spooling: on Windows only.
|
||||
pub const RENAME_FOR_SPOOLER: bool = cfg!(windows);
|
||||
|
||||
/// Decoded size past which a program is left as it is (a whole CJK font is about 20 MB).
|
||||
const MAX_PROGRAM: usize = 64 << 20;
|
||||
|
||||
/// Decoded bytes the whole renaming may read, across every program. A document's fonts rarely
|
||||
/// come to 100 MB; a file of many distinct compressed bombs is read only this far.
|
||||
const MAX_DECODED_IN_ALL: usize = 512 << 20;
|
||||
|
||||
/// More tables than any font has; a larger count is a damaged directory.
|
||||
const MAX_TABLES: usize = 256;
|
||||
|
||||
/// The `name` records that say which font this is: family, unique ID, full name, PostScript name,
|
||||
/// typographic family, Mac compatible full name, PostScript CID name, WWS family and the
|
||||
/// variations PostScript name prefix.
|
||||
const IDENTITY: [u16; 9] = [1, 3, 4, 6, 16, 18, 20, 21, 25];
|
||||
|
||||
/// The font subtypes whose descriptor can hold a TrueType or OpenType program.
|
||||
const DRAWN_WITH_A_PROGRAM: [&[u8]; 5] = [b"TrueType", b"Type1", b"MMType1", b"CIDFontType0", b"CIDFontType2"];
|
||||
|
||||
/// An embedded program and what it will be called.
|
||||
struct Program {
|
||||
name: String,
|
||||
/// A `/FontFile2` program, whose `/Length1` is the decoded length.
|
||||
true_type: bool,
|
||||
}
|
||||
|
||||
/// The fonts drawn with embedded TrueType or OpenType programs, found by scanning every object.
|
||||
#[derive(Default)]
|
||||
struct Found {
|
||||
/// Each program, numbered in object order.
|
||||
programs: BTreeMap<ObjRef, Program>,
|
||||
/// Simple fonts and CIDFonts, with their program.
|
||||
fonts: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Indirect font descriptors, with their program.
|
||||
descriptors: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Type 0 fonts whose CIDFont is an object of its own, with that CIDFont.
|
||||
type0: BTreeMap<ObjRef, ObjRef>,
|
||||
/// Type 0 fonts whose CIDFont is written inline, with the array object holding it when
|
||||
/// `/DescendantFonts` names one, and the CIDFont's program.
|
||||
inline: BTreeMap<ObjRef, (Option<ObjRef>, ObjRef)>,
|
||||
}
|
||||
|
||||
impl Found {
|
||||
/// Note the program `font` draws with, and its descriptor when that is an object of its own;
|
||||
/// the program, if it has one to rename.
|
||||
fn add(&mut self, doc: &Document, font: &Dict) -> Option<ObjRef> {
|
||||
if !font.name(b"Subtype").is_some_and(|s| DRAWN_WITH_A_PROGRAM.contains(&s)) {
|
||||
return None;
|
||||
}
|
||||
let descriptor = font.get(b"FontDescriptor")?;
|
||||
let (program, true_type) = doc.dict(descriptor).and_then(|d| program(doc, &d))?;
|
||||
let number = self.programs.len() + 1;
|
||||
self.programs.entry(program).or_insert_with(|| Program { name: format!("PdfCraftPrint{number}"), true_type });
|
||||
if let Object::Ref(descriptor) = descriptor {
|
||||
self.descriptors.insert(*descriptor, program);
|
||||
}
|
||||
Some(program)
|
||||
}
|
||||
}
|
||||
|
||||
/// `pdf` with every embedded TrueType or OpenType program renamed; `None` when it has none that
|
||||
/// can be.
|
||||
pub fn distinct(pdf: &[u8]) -> Result<Option<Vec<u8>>, PrintError> {
|
||||
distinct_within(pdf, &mut DecodeBudget::new(MAX_DECODED_IN_ALL, 0))
|
||||
}
|
||||
|
||||
/// [`distinct`], decoding the programs against `budget`: once it is spent, the programs not yet
|
||||
/// read keep their names, as one that can't be read does.
|
||||
pub(crate) fn distinct_within(pdf: &[u8], budget: &mut DecodeBudget) -> Result<Option<Vec<u8>>, PrintError> {
|
||||
let mut doc = Document::open(Arc::new(pdf.to_vec()))?;
|
||||
let mut found = find(&doc);
|
||||
// A program that can't be rewritten keeps its names in the PDF too: a viewer that can't load
|
||||
// it either then still picks its substitute by the original name. The lookups below skip it.
|
||||
found.programs.retain(|program, about| rename_program(&mut doc, *program, about, budget));
|
||||
if found.programs.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
for (descriptor, program) in &found.descriptors {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*descriptor, |d| rename(d, b"FontName", &about.name))?;
|
||||
}
|
||||
for (font, program) in &found.fonts {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*font, |d| rename_font(d, &about.name))?;
|
||||
}
|
||||
// A Type 0 font is named after its CIDFont; renaming only the CIDFont would leave the old
|
||||
// name on the font the page uses.
|
||||
for (font, cid) in &found.type0 {
|
||||
let Some(about) = found.fonts.get(cid).and_then(|p| found.programs.get(p)) else { continue };
|
||||
doc.update_dict(*font, |d| rename(d, b"BaseFont", &about.name))?;
|
||||
}
|
||||
for (font, (array, program)) in &found.inline {
|
||||
let Some(about) = found.programs.get(program) else { continue };
|
||||
doc.update_dict(*font, |d| {
|
||||
rename(d, b"BaseFont", &about.name);
|
||||
if let Some(Object::Array(descendants)) = d.get_mut(b"DescendantFonts") {
|
||||
rename_first(descendants, &about.name);
|
||||
}
|
||||
})?;
|
||||
if let Some(array) = array {
|
||||
let Object::Array(mut descendants) = doc.get(*array).as_ref().clone() else { continue };
|
||||
rename_first(&mut descendants, &about.name);
|
||||
doc.set(*array, Object::Array(descendants));
|
||||
}
|
||||
}
|
||||
Ok(Some(write_full(&doc, &SaveOptions::default())?))
|
||||
}
|
||||
|
||||
fn find(doc: &Document) -> Found {
|
||||
let mut found = Found::default();
|
||||
for (reference, object) in doc.scan_objects() {
|
||||
let Object::Dict(font) = object.as_ref() else { continue };
|
||||
if font.name(b"Subtype") == Some(b"Type0") {
|
||||
find_descendant(doc, reference, font, &mut found);
|
||||
} else if let Some(program) = found.add(doc, font) {
|
||||
found.fonts.insert(reference, program);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Note the CIDFont a Type 0 font is named after: an object of its own, renamed when the scan
|
||||
/// reaches it, or written inline, which no scan reaches, so its program is noted here.
|
||||
fn find_descendant(doc: &Document, font: ObjRef, d: &Dict, found: &mut Found) {
|
||||
let Some(descendants) = d.get(b"DescendantFonts") else { return };
|
||||
let array = match descendants {
|
||||
Object::Ref(array) => Some(*array),
|
||||
_ => None,
|
||||
};
|
||||
let descendants = doc.resolve(descendants);
|
||||
match descendants.as_array().and_then(|a| a.first()) {
|
||||
Some(Object::Ref(cid)) => {
|
||||
found.type0.insert(font, *cid);
|
||||
}
|
||||
Some(Object::Dict(cid)) => {
|
||||
if let Some(program) = found.add(doc, cid) {
|
||||
found.inline.insert(font, (array, program));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename a simple font or CIDFont, and its descriptor when that is written inline.
|
||||
fn rename_font(d: &mut Dict, name: &str) {
|
||||
rename(d, b"BaseFont", name);
|
||||
if let Some(Object::Dict(descriptor)) = d.get_mut(b"FontDescriptor") {
|
||||
rename(descriptor, b"FontName", name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename the CIDFont written inline at the head of `/DescendantFonts`.
|
||||
fn rename_first(descendants: &mut [Object], name: &str) {
|
||||
if let Some(Object::Dict(cid)) = descendants.first_mut() {
|
||||
rename_font(cid, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor's TrueType program (`/FontFile2`), else its OpenType one (`/FontFile3` with
|
||||
/// `/Subtype /OpenType`), with whether it is TrueType.
|
||||
fn program(doc: &Document, descriptor: &Dict) -> Option<(ObjRef, bool)> {
|
||||
if let Some(Object::Ref(program)) = descriptor.get(b"FontFile2") {
|
||||
return Some((*program, true));
|
||||
}
|
||||
let Some(Object::Ref(program)) = descriptor.get(b"FontFile3") else { return None };
|
||||
let open_type = matches!(doc.get(*program).as_ref(), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"OpenType"));
|
||||
open_type.then_some((*program, false))
|
||||
}
|
||||
|
||||
/// Set `key` to `name`, keeping the subset tag the old name had.
|
||||
fn rename(d: &mut Dict, key: &[u8], name: &str) {
|
||||
let mut renamed = d.name(key).and_then(subset_tag).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
renamed.extend_from_slice(name.as_bytes());
|
||||
d.set(key.to_vec(), Object::Name(renamed));
|
||||
}
|
||||
|
||||
/// `ABCDEF+` at the start of a subset font's name (ISO 32000-2 §9.9.2).
|
||||
fn subset_tag(name: &[u8]) -> Option<&[u8]> {
|
||||
let tag = name.get(..7)?;
|
||||
let (letters, plus) = tag.split_at(6);
|
||||
(letters.iter().all(u8::is_ascii_uppercase) && plus == b"+").then_some(tag)
|
||||
}
|
||||
|
||||
/// Rewrite the program with its `name` table renamed; whether it was. A program that can't be
|
||||
/// decoded within `budget` or isn't an sfnt with a readable `name` table stays as it is.
|
||||
fn rename_program(doc: &mut Document, program: ObjRef, about: &Program, budget: &mut DecodeBudget) -> bool {
|
||||
let object = doc.get(program);
|
||||
let Object::Stream(stream) = object.as_ref() else { return false };
|
||||
let Ok(bytes) = budget.decode_within(stream, MAX_PROGRAM) else { return false };
|
||||
let Some(renamed) = renamed_sfnt(&bytes, &about.name) else { return false };
|
||||
let mut dict = stream.dict.clone();
|
||||
dict.remove(b"DL");
|
||||
if about.true_type || dict.get(b"Length1").is_some() {
|
||||
dict.set(b"Length1".to_vec(), Object::Int(i64::try_from(renamed.len()).unwrap_or(i64::MAX)));
|
||||
}
|
||||
doc.set(program, Object::Stream(Stream::flate(dict, &renamed)));
|
||||
true
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> Option<u16> {
|
||||
Some(u16::from_be_bytes(bytes.get(at..at.checked_add(2)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
|
||||
Some(u32::from_be_bytes(bytes.get(at..at.checked_add(4)?)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
data.chunks(4).fold(0_u32, |sum, c| {
|
||||
let mut word = [0; 4];
|
||||
for (a, b) in word.iter_mut().zip(c) {
|
||||
*a = *b;
|
||||
}
|
||||
sum.wrapping_add(u32::from_be_bytes(word))
|
||||
})
|
||||
}
|
||||
|
||||
/// `program` with the identity records of its `name` table saying `name`. The new table goes
|
||||
/// after the others, as `pdfcraft_fonts` writes its editor cmap, and the directory entry and
|
||||
/// `head`'s checksum adjustment are brought up to date. `None` when `program` is not a single
|
||||
/// sfnt font with a `name` table that can be read.
|
||||
pub(crate) fn renamed_sfnt(program: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
let version = program.get(..4)?;
|
||||
if version != [0, 1, 0, 0] && version != b"true" && version != b"OTTO" {
|
||||
return None;
|
||||
}
|
||||
let count = usize::from(u16_at(program, 4)?);
|
||||
if count > MAX_TABLES {
|
||||
return None;
|
||||
}
|
||||
let (mut entry, mut head) = (None, None);
|
||||
for at in (0..count).map(|i| 12 + i * 16) {
|
||||
match program.get(at..at + 4)? {
|
||||
b"name" => entry = Some(at),
|
||||
b"head" => head = Some(usize::try_from(u32_at(program, at + 8)?).ok()?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let entry = entry?;
|
||||
let offset = usize::try_from(u32_at(program, entry + 8)?).ok()?;
|
||||
let length = usize::try_from(u32_at(program, entry + 12)?).ok()?;
|
||||
let table = renamed_table(program.get(offset..offset.checked_add(length)?)?, name)?;
|
||||
let mut out = program.to_vec();
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
let at = u32::try_from(out.len()).ok()?;
|
||||
out.get_mut(entry + 4..entry + 8)?.copy_from_slice(&checksum(&table).to_be_bytes());
|
||||
out.get_mut(entry + 8..entry + 12)?.copy_from_slice(&at.to_be_bytes());
|
||||
out.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(table.len()).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&table);
|
||||
if let Some(head) = head {
|
||||
let adjustment = head.checked_add(8)?..head.checked_add(12)?;
|
||||
out.get_mut(adjustment.clone())?.fill(0);
|
||||
let sum = checksum(&out);
|
||||
out.get_mut(adjustment)?.copy_from_slice(&0xb1b0afba_u32.wrapping_sub(sum).to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Where a record's string comes from: the old table's storage (offset and length), or the new
|
||||
/// name in UTF-16BE or in single bytes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Text {
|
||||
Old(u16, u16),
|
||||
Wide,
|
||||
Narrow,
|
||||
}
|
||||
|
||||
/// A `name` table (format 0 or 1) with its identity records saying `name`. The records stay in
|
||||
/// their order, which the format requires to be sorted. An identity record in an encoding that
|
||||
/// can't spell `name`, or any record whose string lies outside the table, is left out; a broken
|
||||
/// language tag fails the table, because records refer to the tags by position.
|
||||
///
|
||||
/// Nothing is copied until the whole table has been read, and then each distinct string once:
|
||||
/// records that shared a string still share it, and the strings stay within what 16-bit offsets
|
||||
/// reach, so a small table can't make the copy large (#4098).
|
||||
fn renamed_table(table: &[u8], name: &str) -> Option<Vec<u8>> {
|
||||
let format = u16_at(table, 0)?;
|
||||
if format > 1 {
|
||||
return None;
|
||||
}
|
||||
let count = usize::from(u16_at(table, 2)?);
|
||||
let storage = usize::from(u16_at(table, 4)?);
|
||||
let old = |offset: u16, length: u16| table.get(storage.checked_add(usize::from(offset))?..)?.get(..usize::from(length));
|
||||
let tags_at = 6 + count * 12;
|
||||
let tag_count = if format == 1 { usize::from(u16_at(table, tags_at)?) } else { 0 };
|
||||
// The records and tags come before the strings, whose offset is 16 bits: a table that claims
|
||||
// more of them overlaps its own strings and is refused.
|
||||
u16::try_from(tags_at + if format == 1 { 2 + tag_count * 4 } else { 0 }).ok()?;
|
||||
let mut records: Vec<([u16; 4], Text)> = Vec::with_capacity(count);
|
||||
for at in (0..count).map(|i| 6 + i * 12) {
|
||||
let field = |i: usize| u16_at(table, at + 2 * i);
|
||||
let key = [field(0)?, field(1)?, field(2)?, field(3)?];
|
||||
let [platform, encoding, _, id] = key;
|
||||
let text = if IDENTITY.contains(&id) {
|
||||
spelling(platform, encoding)
|
||||
} else {
|
||||
let (length, offset) = (field(4)?, field(5)?);
|
||||
old(offset, length).map(|_| Text::Old(offset, length))
|
||||
};
|
||||
if let Some(text) = text {
|
||||
records.push((key, text));
|
||||
}
|
||||
}
|
||||
let mut tags = Vec::with_capacity(tag_count);
|
||||
for at in (0..tag_count).map(|i| tags_at + 2 + 4 * i) {
|
||||
let (length, offset) = (u16_at(table, at)?, u16_at(table, at + 2)?);
|
||||
old(offset, length)?;
|
||||
tags.push(Text::Old(offset, length));
|
||||
}
|
||||
let wide: Vec<u8> = name.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
let mut placed: BTreeMap<Text, [u16; 2]> = BTreeMap::new();
|
||||
let mut strings = Vec::new();
|
||||
// A string's length and offset in the new storage, storing it the first time it is asked for.
|
||||
let mut place = |text: Text| -> Option<[u16; 2]> {
|
||||
if let Some(at) = placed.get(&text) {
|
||||
return Some(*at);
|
||||
}
|
||||
let bytes = match text {
|
||||
Text::Old(offset, length) => old(offset, length)?,
|
||||
Text::Wide => &wide,
|
||||
Text::Narrow => name.as_bytes(),
|
||||
};
|
||||
let at = [u16::try_from(bytes.len()).ok()?, u16::try_from(strings.len()).ok()?];
|
||||
strings.extend_from_slice(bytes);
|
||||
placed.insert(text, at);
|
||||
Some(at)
|
||||
};
|
||||
let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 };
|
||||
let mut out = Vec::with_capacity(header);
|
||||
for v in [format, u16::try_from(records.len()).ok()?, u16::try_from(header).ok()?] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
for (key, text) in &records {
|
||||
for v in key.iter().chain(&place(*text)?) {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
if format == 1 {
|
||||
out.extend_from_slice(&u16::try_from(tags.len()).ok()?.to_be_bytes());
|
||||
for tag in &tags {
|
||||
for v in place(*tag)? {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&strings);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// How `name` (ASCII) is written in a `name` record of this platform and encoding: UTF-16BE for
|
||||
/// Unicode and the Windows Unicode and symbol encodings, single bytes for Macintosh (every Mac
|
||||
/// script encoding starts with ASCII); `None` for the legacy Windows CJK encodings and the ISO and
|
||||
/// custom platforms.
|
||||
fn spelling(platform: u16, encoding: u16) -> Option<Text> {
|
||||
match (platform, encoding) {
|
||||
(0, _) | (3, 0 | 1 | 10) => Some(Text::Wide),
|
||||
(1, _) => Some(Text::Narrow),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
mod font_names;
|
||||
pub mod range;
|
||||
pub mod spool;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -363,7 +363,8 @@ pub fn submit(pdf: &[u8], job: &Job) -> Result<String, crate::PrintError> {
|
||||
if let Some(dll) = missing_print_dll() {
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
craft_print::submit(pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER);
|
||||
craft_print::submit(&pdf, &spooled(job)).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`submit`], told `(sent, sheets)` as each sheet reaches the spooler (Windows; CUPS takes the
|
||||
@@ -372,7 +373,22 @@ pub fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnMut(usiz
|
||||
if let Some(dll) = missing_print_dll() {
|
||||
return Err(unavailable(dll));
|
||||
}
|
||||
craft_print::submit_with_progress(pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
let pdf = spooled_pdf(pdf, crate::font_names::RENAME_FOR_SPOOLER);
|
||||
craft_print::submit_with_progress(&pdf, &spooled(job), progress).map_err(|e| crate::PrintError::Spool(e.to_string()))
|
||||
}
|
||||
|
||||
/// The PDF as the system spooler takes it: with `rename` (Windows), its embedded TrueType and
|
||||
/// OpenType programs renamed, so Windows can't draw a subset's glyph numbers with an installed
|
||||
/// font of the same name (#3132, [`crate::font_names`]). A PDF that can't be read back goes as it
|
||||
/// is: the renaming only protects the text from Windows, and the job shouldn't fail over it.
|
||||
pub(crate) fn spooled_pdf(pdf: &[u8], rename: bool) -> std::borrow::Cow<'_, [u8]> {
|
||||
if !rename {
|
||||
return std::borrow::Cow::Borrowed(pdf);
|
||||
}
|
||||
match crate::font_names::distinct(pdf) {
|
||||
Ok(Some(renamed)) => std::borrow::Cow::Owned(renamed),
|
||||
Ok(None) | Err(_) => std::borrow::Cow::Borrowed(pdf),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_printer_preferences(printer: &str) -> Result<(), crate::PrintError> {
|
||||
|
||||
@@ -1332,6 +1332,7 @@ fn driver_options_take_the_ppds_own_names_in_the_ui_language() {
|
||||
}
|
||||
|
||||
mod appearances;
|
||||
mod font_names;
|
||||
|
||||
#[test]
|
||||
fn repeated_content_references_share_one_decode_budget() {
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! #3132: embedded TrueType programs go to the Windows spooler under names no installed font has.
|
||||
|
||||
use super::*;
|
||||
use crate::font_names::{distinct, distinct_within, renamed_sfnt};
|
||||
use crate::spool::spooled_pdf;
|
||||
use pdfcraft_cos::DecodeBudget;
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
use skrifa::raw::TableProvider as _;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{FontRef, MetadataProvider as _};
|
||||
|
||||
/// Inter (OFL, `ATTRIBUTION.toml`) stands in for the subset ReportLab embeds as `AAAAAA+ArialMT`.
|
||||
const INTER: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
|
||||
fn name_of(program: &[u8], id: StringId) -> Option<String> {
|
||||
FontRef::new(program).unwrap().localized_strings(id).english_or_first().map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// A TrueType font over `program`, named `base_font`, as ReportLab writes one; its descriptor is
|
||||
/// indirect.
|
||||
fn true_type_font(doc: &mut Document, base_font: &str, program: ObjRef) -> ObjRef {
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
|
||||
descriptor.set(b"Flags".to_vec(), Object::Int(32));
|
||||
descriptor.set(b"FontBBox".to_vec(), Object::Array([-500, -300, 1500, 1000].into_iter().map(Object::Int).collect()));
|
||||
for (key, value) in [(b"ItalicAngle".as_slice(), 0), (b"Ascent", 900), (b"Descent", -200), (b"CapHeight", 700), (b"StemV", 80)] {
|
||||
descriptor.set(key.to_vec(), Object::Int(value));
|
||||
}
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let descriptor = doc.add(Object::Dict(descriptor));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("TrueType"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
|
||||
font.set(b"FontDescriptor".to_vec(), Object::Ref(descriptor));
|
||||
doc.add(Object::Dict(font))
|
||||
}
|
||||
|
||||
fn program_stream(doc: &mut Document) -> ObjRef {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Length1".to_vec(), Object::Int(INTER.len() as i64));
|
||||
doc.add(Object::Stream(Stream::flate(d, INTER)))
|
||||
}
|
||||
|
||||
/// One 200×300 page writing "Hola" in a regular and a bold Arial subset, the way the report's
|
||||
/// proposal is set, and its print-ready PDF.
|
||||
fn print_ready() -> Vec<u8> {
|
||||
let mut doc = fixture(1);
|
||||
let (regular, bold) = (program_stream(&mut doc), program_stream(&mut doc));
|
||||
let regular = true_type_font(&mut doc, "AAAAAA+ArialMT", regular);
|
||||
let bold = true_type_font(&mut doc, "AAAAAA+Arial-BoldMT", bold);
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(regular));
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(bold));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let contents = doc.add(Object::Stream(Stream::flate(Dict::new(), b"BT /F1 40 Tf 10 200 Td (Hola) Tj ET BT /F2 40 Tf 10 100 Td (Hola) Tj ET")));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| {
|
||||
d.remove(b"Annots");
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(contents));
|
||||
})
|
||||
.unwrap();
|
||||
let settings = Settings { pages: vec![0], paper: (200.0, 300.0), layout: Layout::Size(SizeMode::Actual), ..Settings::default() };
|
||||
impose(&doc, &settings).unwrap()
|
||||
}
|
||||
|
||||
fn pixels(pdf: &[u8]) -> Vec<u8> {
|
||||
let page = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default()).render(RenderRequest {
|
||||
page: 0,
|
||||
kind: RequestKind::Pixels,
|
||||
tile: None,
|
||||
scale: 1.0,
|
||||
tag: 0,
|
||||
preview: false,
|
||||
});
|
||||
assert_eq!(page.error, None);
|
||||
page.rgba[..].to_vec()
|
||||
}
|
||||
|
||||
/// Each TrueType font in `pdf`: its `/BaseFont`, its descriptor's `/FontName`, its decoded
|
||||
/// program and that program's `/Length1`.
|
||||
fn true_type_fonts(pdf: &[u8]) -> Vec<(String, String, Vec<u8>, i64)> {
|
||||
let doc = Document::open(Arc::new(pdf.to_vec())).unwrap();
|
||||
let mut fonts = Vec::new();
|
||||
for (_, object) in doc.scan_objects() {
|
||||
let Object::Dict(font) = object.as_ref() else { continue };
|
||||
if font.name(b"Subtype") != Some(b"TrueType") {
|
||||
continue;
|
||||
}
|
||||
let descriptor = doc.dict(font.get(b"FontDescriptor").unwrap()).unwrap();
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("no program") };
|
||||
let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap();
|
||||
fonts.push((name(font, b"BaseFont"), name(&descriptor, b"FontName"), program.decoded().unwrap(), program.dict.int(b"Length1").unwrap()));
|
||||
}
|
||||
fonts.sort();
|
||||
fonts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_gets_embedded_subsets_under_names_no_installed_font_has() {
|
||||
let pdf = print_ready();
|
||||
let before = true_type_fonts(&pdf);
|
||||
assert_eq!(before.iter().map(|f| f.0.as_str()).collect::<Vec<_>>(), ["AAAAAA+Arial-BoldMT", "AAAAAA+ArialMT"]);
|
||||
|
||||
let spooled = spooled_pdf(&pdf, true);
|
||||
let after = true_type_fonts(&spooled);
|
||||
|
||||
// Each program has a name of its own, in the PDF and in its `name` table; the subset tag stays.
|
||||
let names: Vec<_> = after.iter().map(|f| (f.0.as_str(), f.1.as_str())).collect();
|
||||
assert_eq!(names, [("AAAAAA+PdfCraftPrint1", "AAAAAA+PdfCraftPrint1"), ("AAAAAA+PdfCraftPrint2", "AAAAAA+PdfCraftPrint2")]);
|
||||
for (n, (_, _, program, length1)) in after.iter().enumerate() {
|
||||
let name = format!("PdfCraftPrint{}", n + 1);
|
||||
for id in [StringId::FAMILY_NAME, StringId::FULL_NAME, StringId::POSTSCRIPT_NAME, StringId::UNIQUE_ID] {
|
||||
assert_eq!(name_of(program, id).as_deref(), Some(name.as_str()), "{id:?}");
|
||||
}
|
||||
assert_eq!(name_of(program, StringId::SUBFAMILY_NAME).as_deref(), Some("Regular"));
|
||||
assert_eq!(*length1, program.len() as i64);
|
||||
// Nothing that draws a glyph changed: the same glyph for every character, and the same
|
||||
// tables but `name` and `head`'s checksum adjustment.
|
||||
let (old, new) = (FontRef::new(INTER).unwrap(), FontRef::new(program).unwrap());
|
||||
assert_eq!(old.charmap().mappings().collect::<Vec<_>>(), new.charmap().mappings().collect::<Vec<_>>());
|
||||
let table = |font: &FontRef, tag| {
|
||||
let mut data = font.table_data(tag).unwrap().as_bytes().to_vec();
|
||||
if &tag == b"head" {
|
||||
data[8..12].fill(0);
|
||||
}
|
||||
data
|
||||
};
|
||||
for tag in old.table_directory.table_records().iter().map(|r| r.tag()).filter(|t| t != b"name") {
|
||||
assert_eq!(table(&old, tag), table(&new, tag), "{tag}");
|
||||
}
|
||||
// `head`'s checksum adjustment holds for the new file.
|
||||
let sum = program.chunks(4).fold(0_u32, |s, c| s.wrapping_add(u32::from_be_bytes([0, 1, 2, 3].map(|i| c.get(i).copied().unwrap_or(0)))));
|
||||
assert_eq!(sum, 0xb1b0afba);
|
||||
}
|
||||
assert!(before.iter().all(|f| name_of(&f.2, StringId::FAMILY_NAME).as_deref() == Some("Inter")));
|
||||
|
||||
// The page draws exactly as before, and draws something.
|
||||
let (old, new) = (pixels(&pdf), pixels(&spooled));
|
||||
assert!(old.chunks(4).any(|p| p[0] < 128), "the text is drawn");
|
||||
assert!(old == new, "renaming changed what prints");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_platforms_and_pdfs_without_embedded_programs_spool_as_they_are() {
|
||||
let pdf = print_ready();
|
||||
assert!(matches!(spooled_pdf(&pdf, false), std::borrow::Cow::Borrowed(p) if p == pdf.as_slice()));
|
||||
let helvetica = impose(&fixture(1), &Settings { pages: vec![0], paper: (200.0, 300.0), ..Settings::default() }).unwrap();
|
||||
assert_eq!(distinct(&helvetica).unwrap(), None);
|
||||
assert!(matches!(spooled_pdf(&helvetica, true), std::borrow::Cow::Borrowed(_)));
|
||||
// Not a PDF: it goes as it is, and the spooler reports what it makes of it.
|
||||
assert!(matches!(spooled_pdf(b"not a pdf", true), std::borrow::Cow::Borrowed(b"not a pdf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_program_that_cannot_be_rewritten_keeps_its_name_in_the_pdf_too() {
|
||||
// A viewer that can't load the program either picks a substitute by the PDF's name, so that
|
||||
// name must still say Arial.
|
||||
let mut doc = fixture(1);
|
||||
let good = program_stream(&mut doc);
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Length1".to_vec(), Object::Int(10));
|
||||
let broken = doc.add(Object::Stream(Stream::flate(d, b"not a font")));
|
||||
let good = true_type_font(&mut doc, "AAAAAA+ArialMT", good);
|
||||
let broken = true_type_font(&mut doc, "BAAAAA+Arial-BoldMT", broken);
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(good));
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(broken));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
let names: Vec<_> = true_type_fonts(&spooled_pdf(&pdf, true)).into_iter().map(|f| (f.0, f.1, f.2 == b"not a font")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
("AAAAAA+PdfCraftPrint1".to_owned(), "AAAAAA+PdfCraftPrint1".to_owned(), false),
|
||||
("BAAAAA+Arial-BoldMT".to_owned(), "BAAAAA+Arial-BoldMT".to_owned(), true),
|
||||
]
|
||||
);
|
||||
// With nothing it can rewrite, the PDF goes as it is.
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F2".to_vec(), Object::Ref(broken));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
assert_eq!(distinct(&write_full(&doc, &SaveOptions::default()).unwrap()).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn programs_past_the_shared_decode_budget_keep_their_names() {
|
||||
// Each program decodes to Inter's whole length; the budget covers one and a half.
|
||||
let pdf = print_ready();
|
||||
let mut budget = DecodeBudget::new(INTER.len() * 3 / 2, 0);
|
||||
let renamed = distinct_within(&pdf, &mut budget).unwrap().unwrap();
|
||||
let names: Vec<_> = true_type_fonts(&renamed).into_iter().map(|f| (f.0, name_of(&f.2, StringId::FAMILY_NAME).unwrap())).collect();
|
||||
let (new, old): (Vec<_>, Vec<_>) = names.iter().partition(|(base_font, _)| base_font.contains("PdfCraftPrint"));
|
||||
assert_eq!((new.len(), old.len()), (1, 1), "{names:?}");
|
||||
assert!(new[0].0.starts_with("AAAAAA+PdfCraftPrint") && new[0].1.starts_with("PdfCraftPrint"), "{names:?}");
|
||||
assert!(old[0].0.starts_with("AAAAAA+Arial") && old[0].1 == "Inter", "{names:?}");
|
||||
// With the default allowance both are renamed.
|
||||
assert_eq!(true_type_fonts(&distinct(&pdf).unwrap().unwrap()).iter().filter(|f| f.0.contains("PdfCraftPrint")).count(), 2);
|
||||
}
|
||||
|
||||
/// A CIDFontType2 named `base_font` over `program`, written as a dictionary with its descriptor
|
||||
/// inline, as some producers put it straight into `/DescendantFonts`.
|
||||
fn inline_cid_font(base_font: &str, program: ObjRef) -> Dict {
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name(base_font));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"Type".to_vec(), Object::name("Font"));
|
||||
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
cid.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
cid
|
||||
}
|
||||
|
||||
fn type0_font(doc: &mut Document, base_font: &str, descendants: Object) -> ObjRef {
|
||||
let mut type0 = Dict::new();
|
||||
type0.set(b"Type".to_vec(), Object::name("Font"));
|
||||
type0.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
type0.set(b"BaseFont".to_vec(), Object::name(base_font));
|
||||
type0.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
type0.set(b"DescendantFonts".to_vec(), descendants);
|
||||
doc.add(Object::Dict(type0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cidfont_written_inside_its_type0_font_is_renamed_with_it() {
|
||||
let mut doc = fixture(1);
|
||||
let (shared, own) = (program_stream(&mut doc), program_stream(&mut doc));
|
||||
let simple = true_type_font(&mut doc, "AAAAAA+ArialMT", shared);
|
||||
// In `/DescendantFonts` itself, sharing the simple font's program; and in an array object of
|
||||
// its own, over a program nothing else uses.
|
||||
let direct = type0_font(&mut doc, "BAAAAA+ArialMT-Identity-H", Object::Array(vec![Object::Dict(inline_cid_font("BAAAAA+ArialMT", shared))]));
|
||||
let array = doc.add(Object::Array(vec![Object::Dict(inline_cid_font("CAAAAA+ArialMT", own))]));
|
||||
let in_array = type0_font(&mut doc, "CAAAAA+ArialMT-Identity-H", Object::Ref(array));
|
||||
let mut fonts = Dict::new();
|
||||
for (key, font) in [(b"F1", simple), (b"F2", direct), (b"F3", in_array)] {
|
||||
fonts.set(key.to_vec(), Object::Ref(font));
|
||||
}
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap();
|
||||
let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap();
|
||||
let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
|
||||
let name = |d: &Dict, key: &[u8]| String::from_utf8(d.name(key).unwrap().to_vec()).unwrap();
|
||||
let simple = renamed.dict(fonts.get(b"F1").unwrap()).unwrap();
|
||||
for (key, tag) in [(b"F2", "BAAAAA+"), (b"F3", "CAAAAA+")] {
|
||||
let type0 = renamed.dict(fonts.get(key).unwrap()).unwrap();
|
||||
let descendants = renamed.resolve(type0.get(b"DescendantFonts").unwrap());
|
||||
let Some(Object::Dict(cid)) = descendants.as_array().and_then(|a| a.first()) else { panic!("inline CIDFont") };
|
||||
let Some(Object::Dict(descriptor)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") };
|
||||
let program = renamed.resolve(descriptor.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("program") };
|
||||
let family = name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).unwrap();
|
||||
// The Type 0 font, its CIDFont and the descriptor all say the program's new name.
|
||||
let expected = format!("{tag}{family}");
|
||||
assert!(family.starts_with("PdfCraftPrint"), "{family}");
|
||||
assert_eq!(
|
||||
(name(&type0, b"BaseFont"), name(cid, b"BaseFont"), name(descriptor, b"FontName")),
|
||||
(expected.clone(), expected.clone(), expected)
|
||||
);
|
||||
}
|
||||
// The shared program has one name, whichever font led to it.
|
||||
let shared_name = name(&simple, b"BaseFont").replace("AAAAAA+", "");
|
||||
assert_eq!(name(&renamed.dict(fonts.get(b"F2").unwrap()).unwrap(), b"BaseFont"), format!("BAAAAA+{shared_name}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_sharing_a_program_share_its_name_and_type0_fonts_follow_their_cidfont() {
|
||||
let mut doc = fixture(1);
|
||||
let program = program_stream(&mut doc);
|
||||
let simple = true_type_font(&mut doc, "BCDEFG+Calibri", program);
|
||||
let again = true_type_font(&mut doc, "Calibri", program);
|
||||
// A Type 0 font over a CIDFontType2 whose descriptor is written inline.
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
|
||||
descriptor.set(b"FontName".to_vec(), Object::name("HIJKLM+Calibri"));
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut cid = Dict::new();
|
||||
cid.set(b"Type".to_vec(), Object::name("Font"));
|
||||
cid.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
|
||||
cid.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri"));
|
||||
cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
let cid = doc.add(Object::Dict(cid));
|
||||
let mut type0 = Dict::new();
|
||||
type0.set(b"Type".to_vec(), Object::name("Font"));
|
||||
type0.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
type0.set(b"BaseFont".to_vec(), Object::name("HIJKLM+Calibri-Identity-H"));
|
||||
type0.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
type0.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Ref(cid)]));
|
||||
let type0 = doc.add(Object::Dict(type0));
|
||||
let mut fonts = Dict::new();
|
||||
for (key, font) in [(b"F1", simple), (b"F2", again), (b"F3", type0)] {
|
||||
fonts.set(key.to_vec(), Object::Ref(font));
|
||||
}
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
|
||||
let pdf = write_full(&doc, &SaveOptions::default()).unwrap();
|
||||
|
||||
// The file is written anew, so the fonts are found again through the page.
|
||||
let renamed = Document::open(Arc::new(distinct(&pdf).unwrap().unwrap())).unwrap();
|
||||
let page = renamed.dict(&Object::Ref(pdfcraft_model::pages(&renamed)[0].obj)).unwrap();
|
||||
let fonts = renamed.dict(renamed.dict(page.get(b"Resources").unwrap()).unwrap().get(b"Font").unwrap()).unwrap();
|
||||
let font = |key: &[u8]| renamed.dict(fonts.get(key).unwrap()).unwrap();
|
||||
let base_font = |d: &Dict| String::from_utf8(d.name(b"BaseFont").unwrap().to_vec()).unwrap();
|
||||
assert_eq!(base_font(&font(b"F1")), "BCDEFG+PdfCraftPrint1");
|
||||
assert_eq!(base_font(&font(b"F2")), "PdfCraftPrint1");
|
||||
assert_eq!(base_font(&font(b"F3")), "HIJKLM+PdfCraftPrint1");
|
||||
let Some(Object::Array(descendants)) = font(b"F3").get(b"DescendantFonts").cloned() else { panic!("descendants") };
|
||||
let cid = renamed.dict(&descendants[0]).unwrap();
|
||||
assert_eq!(base_font(&cid), "HIJKLM+PdfCraftPrint1");
|
||||
let Some(Object::Dict(inline)) = cid.get(b"FontDescriptor") else { panic!("inline descriptor") };
|
||||
assert_eq!(inline.name(b"FontName"), Some(b"HIJKLM+PdfCraftPrint1".as_slice()));
|
||||
// Renamed once, not once per font.
|
||||
let program = renamed.resolve(inline.get(b"FontFile2").unwrap());
|
||||
let Object::Stream(program) = program.as_ref() else { panic!("program") };
|
||||
assert_eq!(name_of(&program.decoded().unwrap(), StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1"));
|
||||
}
|
||||
|
||||
/// A `name` table of `format` with the given records `(platform, encoding, language, id, text)`
|
||||
/// and language tags, inside a minimal sfnt with only that table.
|
||||
fn sfnt_with_name(format: u16, records: &[(u16, u16, u16, u16, &[u8])], tags: &[&[u8]]) -> Vec<u8> {
|
||||
let mut table = Vec::new();
|
||||
let mut strings = Vec::new();
|
||||
table.extend_from_slice(&format.to_be_bytes());
|
||||
table.extend_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
let header = 6 + records.len() * 12 + if format == 1 { 2 + tags.len() * 4 } else { 0 };
|
||||
table.extend_from_slice(&(header as u16).to_be_bytes());
|
||||
for (platform, encoding, language, id, text) in records {
|
||||
for v in [*platform, *encoding, *language, *id, text.len() as u16, strings.len() as u16] {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend_from_slice(text);
|
||||
}
|
||||
if format == 1 {
|
||||
table.extend_from_slice(&(tags.len() as u16).to_be_bytes());
|
||||
for tag in tags {
|
||||
table.extend_from_slice(&(tag.len() as u16).to_be_bytes());
|
||||
table.extend_from_slice(&(strings.len() as u16).to_be_bytes());
|
||||
strings.extend_from_slice(tag);
|
||||
}
|
||||
}
|
||||
table.extend_from_slice(&strings);
|
||||
sfnt_with_table(&table)
|
||||
}
|
||||
|
||||
/// A minimal sfnt with `table` as its only table, `name`.
|
||||
fn sfnt_with_table(table: &[u8]) -> Vec<u8> {
|
||||
let mut sfnt = vec![0, 1, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
|
||||
sfnt.extend_from_slice(b"name");
|
||||
sfnt.extend_from_slice(&0_u32.to_be_bytes());
|
||||
sfnt.extend_from_slice(&28_u32.to_be_bytes());
|
||||
sfnt.extend_from_slice(&(table.len() as u32).to_be_bytes());
|
||||
sfnt.extend_from_slice(table);
|
||||
sfnt
|
||||
}
|
||||
|
||||
/// A format-0 `name` table whose `copies` copyright records all point at one `length`-byte
|
||||
/// string, then a family record after it; `storage` overrides where the table says its strings
|
||||
/// start.
|
||||
fn shared_string_table(copies: usize, length: usize, storage: Option<u16>) -> Vec<u8> {
|
||||
let count = copies + 1;
|
||||
let mut table = Vec::new();
|
||||
for v in [0, count as u16, storage.unwrap_or((6 + count * 12) as u16)] {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let copyright = [3, 1, 0x409, 0, length as u16, 0];
|
||||
for v in (0..copies).flat_map(|_| copyright).chain([3, 1, 0x409, 1, 2, length as u16]) {
|
||||
table.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
table.extend(std::iter::repeat_n(b'c', length));
|
||||
table.extend_from_slice(&[0, b'A']);
|
||||
table
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_that_share_one_string_keep_sharing_it() {
|
||||
// A thousand records over one 60,000-byte string. Each given its own copy they would take
|
||||
// 60 MB and lie past what 16-bit offsets reach; stored once, the table is as small as before.
|
||||
let table = shared_string_table(1024, 60_000, None);
|
||||
let renamed = renamed_sfnt(&sfnt_with_table(&table), "PdfCraftPrint1").unwrap();
|
||||
let font = FontRef::new(&renamed).unwrap();
|
||||
let name = font.name().unwrap();
|
||||
let copyright: std::collections::BTreeSet<_> =
|
||||
name.name_record().iter().filter(|r| r.name_id().to_u16() == 0).map(|r| (r.string_offset().to_u32(), r.length())).collect();
|
||||
assert_eq!(copyright.len(), 1, "every copyright record points at the one string");
|
||||
assert!(font.table_data(skrifa::raw::types::Tag::new(b"name")).unwrap().len() <= table.len() + 64);
|
||||
assert_eq!(name_of(&renamed, StringId::FAMILY_NAME).as_deref(), Some("PdfCraftPrint1"));
|
||||
// 60,000 bytes of UTF-16BE: 30,000 characters.
|
||||
assert_eq!(name_of(&renamed, StringId::COPYRIGHT_NOTICE).map(|c| c.chars().count()), Some(60_000 / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_table_claiming_more_records_than_fit_before_its_strings_is_refused() {
|
||||
// The case reported on #4098: 65,535 records over one 65,535-byte string, the strings said to
|
||||
// start inside the records. Copying each record's string before checking took about 4 GiB.
|
||||
let table = shared_string_table(65_534, 65_535, Some(6));
|
||||
assert_eq!(renamed_sfnt(&sfnt_with_table(&table), "X"), None);
|
||||
}
|
||||
|
||||
/// A `name` record: platform, encoding, language, name ID and the string's bytes.
|
||||
type Record = (u16, u16, u16, u16, Vec<u8>);
|
||||
|
||||
/// The records and language tags of the renamed font's `name` table.
|
||||
fn records_of(sfnt: &[u8]) -> (Vec<Record>, Vec<Vec<u8>>) {
|
||||
let font = FontRef::new(sfnt).unwrap();
|
||||
let name = font.name().unwrap();
|
||||
let data = name.string_data();
|
||||
let text = |length: u16, offset: u16| data.as_bytes()[offset as usize..][..length as usize].to_vec();
|
||||
let records = name
|
||||
.name_record()
|
||||
.iter()
|
||||
.map(|r| (r.platform_id(), r.encoding_id(), r.language_id(), r.name_id().to_u16(), text(r.length(), r.string_offset().to_u32() as u16)))
|
||||
.collect();
|
||||
let tags = name.lang_tag_record().unwrap_or_default().iter().map(|t| text(t.length(), t.lang_tag_offset().to_u32() as u16)).collect();
|
||||
(records, tags)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_records_are_respelled_per_platform_and_the_rest_are_kept() {
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let arial = utf16("Arial");
|
||||
let copyright = utf16("© The Monotype Corporation");
|
||||
let sjis_family: &[u8] = &[0x82, 0xa0];
|
||||
let sfnt = sfnt_with_name(
|
||||
1,
|
||||
&[
|
||||
(1, 0, 0, 1, b"Arial"),
|
||||
(1, 0, 0, 2, b"Bold"),
|
||||
(3, 1, 0x409, 0, ©right),
|
||||
(3, 1, 0x409, 1, &arial),
|
||||
(3, 1, 0x409, 6, &utf16("Arial-BoldMT")),
|
||||
(3, 2, 0x411, 1, sjis_family),
|
||||
(3, 1, 0x8000, 4, &utf16("Arial Bold")),
|
||||
],
|
||||
&[b"\0e\0n"],
|
||||
);
|
||||
let renamed = renamed_sfnt(&sfnt, "PdfCraftPrint7").unwrap();
|
||||
let (records, tags) = records_of(&renamed);
|
||||
assert_eq!(
|
||||
records,
|
||||
[
|
||||
(1, 0, 0, 1, b"PdfCraftPrint7".to_vec()),
|
||||
(1, 0, 0, 2, b"Bold".to_vec()),
|
||||
(3, 1, 0x409, 0, copyright),
|
||||
(3, 1, 0x409, 1, utf16("PdfCraftPrint7")),
|
||||
(3, 1, 0x409, 6, utf16("PdfCraftPrint7")),
|
||||
// The Shift-JIS family can't be respelled in ASCII here, so it goes.
|
||||
(3, 1, 0x8000, 4, utf16("PdfCraftPrint7")),
|
||||
]
|
||||
);
|
||||
assert_eq!(tags, [b"\0e\0n".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_programs_are_left_alone_and_never_panic() {
|
||||
let good = sfnt_with_name(0, &[(3, 1, 0x409, 1, &[0, b'A'])], &[]);
|
||||
assert!(renamed_sfnt(&good, "X").is_some());
|
||||
// Every truncation, of the small font and of the start of a real one.
|
||||
for n in 0..good.len() {
|
||||
let _ = renamed_sfnt(&good[..n], "X");
|
||||
}
|
||||
for n in (0..2048).chain([INTER.len() - 1]) {
|
||||
let _ = renamed_sfnt(&INTER[..n], "X");
|
||||
}
|
||||
// A collection, a CFF-in-name-only header, an absurd table count, a `name` entry past the
|
||||
// end, a format-2 `name` table and a string past the table's end.
|
||||
let mut collection = good.clone();
|
||||
collection[..4].copy_from_slice(b"ttcf");
|
||||
assert_eq!(renamed_sfnt(&collection, "X"), None);
|
||||
let mut many = good.clone();
|
||||
many[4..6].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&many, "X"), None);
|
||||
let mut past = good.clone();
|
||||
past[20..24].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&past, "X"), None);
|
||||
let mut format2 = good.clone();
|
||||
format2[28..30].copy_from_slice(&2_u16.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&format2, "X"), None);
|
||||
let mut long = sfnt_with_name(0, &[(3, 1, 0x409, 0, &[0, b'C']), (3, 1, 0x409, 1, &[0, b'A'])], &[]);
|
||||
let first_length = 28 + 6 + 8;
|
||||
long[first_length..first_length + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
let (records, _) = records_of(&renamed_sfnt(&long, "X").unwrap());
|
||||
assert_eq!(records, [(3, 1, 0x409, 1, vec![0, b'X'])], "the broken copyright record is left out");
|
||||
// A broken language tag fails the table, since records point at tags by position.
|
||||
let mut tagged = sfnt_with_name(1, &[(3, 1, 0x409, 1, &[0, b'A'])], &[b"\0e"]);
|
||||
let tag_length = 28 + 6 + 12 + 2;
|
||||
tagged[tag_length..tag_length + 2].copy_from_slice(&u16::MAX.to_be_bytes());
|
||||
assert_eq!(renamed_sfnt(&tagged, "X"), None);
|
||||
}
|
||||
@@ -8202,7 +8202,7 @@ status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["printers", "printer_options", "doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::spooler_arguments_and_printer_list", "crates/print/src/tests.rs::a_job_names_its_title_and_owner_to_the_spooler", "crates/print/src/tests.rs::a_job_keeps_the_title_and_owner_it_already_sets", "crates/print/src/tests.rs::a_submitted_job_reaches_lp_named", "crates/print/src/tests.rs::driverless_printers_join_the_list_without_a_queue", "crates/print/src/tests.rs::windows_printers_carry_one_default_and_keep_their_names", "crates/print/src/tests.rs::printer_options_come_from_the_ppd", "crates/print/src/tests.rs::printer_option_defaults_and_job_arguments", "crates/ui-egui/tests/it/print.rs::print_dialog_shows_the_printer_driver_options", "crates/ui-egui/tests/it/print.rs::printers_listed_in_the_background_preselect_the_default_unless_one_was_picked", "crates/ui-egui/src/workflows/web_print.rs::resolution_falls_with_the_sheet_count_and_long_jobs_are_refused", "crates/ui-egui/src/workflows/web_print.rs::large_sheets_are_drawn_within_the_pixel_cap_and_bad_sizes_are_refused", "crates/ui-egui/src/workflows/web_print.rs::a_sink_error_or_a_page_that_isnt_there_stops_the_job", "crates/print/src/tests.rs::a_command_that_never_answers_is_stopped_at_the_deadline", "crates/ui-egui/tests/it/print.rs::printer_options_are_read_off_the_gui_thread_and_a_dead_read_offers_nothing"]
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer."
|
||||
notes = "CUPS queues via lpstat -p -d and driverless destinations without a queue via lpstat -e; on Windows shared craft-print lists the queues (EnumPrinters), the default (GetDefaultPrinter) and each driver's two-sided support in this process, and draws jobs as vectors with Windows.Data.Pdf into the XPS print API with a driver-validated PrintTicket (exact paper, orientation, no scaling; portrait and landscape sheets in one job via /Rotate), starting no PowerShell, so printing works under App Control script enforcement (#1107); the printer list and submission run on workers; Save as PDF on every platform. Properties…: on CUPS the driver's own PPD job options (tray, paper type, finishing …, not installable options or what the dialog sets) for this job, sent as lp -o; on Windows the driver's Printing Preferences window (printui), saved as that printer's per-user defaults rather than per job. On the web: \"Browser's print dialog\" (the default there) reaches the system's printers through the browser; PdfCraft draws the sheets itself (300 dpi up to 100 sheets, 200 up to 300, 150 up to 1,000; at most 64 megapixels a sheet and 1 GiB a job) and prints them from a hidden iframe, one per printed page, a landscape sheet among portrait ones turned onto the paper; copies, two-sided and colour are chosen in the browser's dialog; a browser that refuses to print gets the print-ready PDF as a download. Checked in headless Chromium, not yet in Firefox or Safari; pictures, not vectors; sheets of another paper size print at their own size only where the browser honours named @page sizes (Chromium doesn't). Properties… on CUPS reads the driver options off the GUI thread and gives lpoptions ten seconds before the PPD defaults stand (#1818); on macOS lp jobs also carry the job name and owner (com.apple.print.JobInfo.PMJobName and PMJobOwner) that the system print dialog sends and some vendors' filters need (#5743). Not yet checked on a physical Kyocera printer. The copy sent to the Windows spooler renames every embedded TrueType/OpenType program (name table, /BaseFont, /FontName), because Windows drew a subset's renumbered glyphs with the installed font of the same name and printed shifted letters (#3132); not yet checked on a physical Windows printer."
|
||||
|
||||
[[feature]]
|
||||
id = "print.copies-collate"
|
||||
|
||||
Reference in new issue
Block a user