mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
krilla: TrueType subsets keep the font's OS/2 table (embedding permissions)
* krilla: TrueType subsets keep the font's OS/2 table (embedding permissions) The subsetter writes only the tables it subsets, so a FontFile2 program had no OS/2 table and the font's embedding permissions (OS/2.fsType) never reached the PDF: a Preview & Print or Restricted font looked unrestricted to anything reading the file later (designcraft#1781). Patch 18 adds the source font's OS/2 table, byte for byte, to a TrueType subset that lacks one, rewriting the table directory with its checksums and head.checkSumAdjustment. CFF programs are still embedded bare. Ledger, patch file and SOURCE.json updated; craft-pdf's new font_tables test checks the table, its fsType and the font's checksums. * krilla: the OS/2 test checks the embedded program is a subset Its sfnt and re-read checks also pass for the source font embedded whole. A subset has fewer glyphs than the fixture's 11, and a shorter glyf. --------- Co-authored-by: Hamza <mail.hamzanaveed@gmail.com>
This commit is contained in:
1 parent
5a37e3e383
commit
9b7b748fbb
5 files changed
+311
-1
No files matched your search
@@ -0,0 +1,110 @@
|
||||
//! The font programs the writer embeds keep the source font's `OS/2` table (#1781 in DesignCraft):
|
||||
//! its `fsType` is where the font states its embedding rights, and the subsetter writes none.
|
||||
#![cfg(feature = "write")]
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use backend::geom::Point;
|
||||
use backend::page::PageSettings;
|
||||
use backend::text::{GlyphId, KrillaGlyph};
|
||||
use craft_pdf::write::{self, Metadata, Writer, backend};
|
||||
use hayro_syntax::object::{Object, Stream};
|
||||
|
||||
/// A two-glyph TrueType font with an `OS/2` table whose `fsType` is 4 (Preview & Print).
|
||||
const TINY_GLYF: &[u8] = include_bytes!("../../fonts-build/tests/fixture/subset/Tiny-glyf.ttf");
|
||||
|
||||
/// `text` set in `font_bytes`, one glyph per character, as a one-page PDF.
|
||||
fn document(font_bytes: &[u8], text: &str) -> Vec<u8> {
|
||||
let face = ttf_parser::Face::parse(font_bytes, 0).unwrap();
|
||||
let glyphs: Vec<_> = text
|
||||
.char_indices()
|
||||
.map(|(start, c)| {
|
||||
let id = face.glyph_index(c).unwrap_or_else(|| panic!("the fixture covers {c:?}"));
|
||||
let advance = f32::from(face.glyph_hor_advance(id).unwrap()) / f32::from(face.units_per_em());
|
||||
KrillaGlyph::new(GlyphId::new(u32::from(id.0)), advance, 0.0, 0.0, 0.0, start..start + c.len_utf8(), None)
|
||||
})
|
||||
.collect();
|
||||
let font = write::font(font_bytes, 0, None).unwrap();
|
||||
let mut writer = Writer::new(backend::SerializeSettings::default(), Metadata::default());
|
||||
let mut page = writer.document_mut().start_page_with(PageSettings::from_wh(200.0, 100.0).unwrap());
|
||||
let mut surface = page.surface();
|
||||
surface.draw_glyphs(Point::from_xy(20.0, 50.0), &glyphs, font, text, 18.0, false);
|
||||
surface.finish();
|
||||
page.finish();
|
||||
writer.finish().unwrap()
|
||||
}
|
||||
|
||||
/// The decoded `FontFile2` programs of `pdf`, through the font descriptors that name them.
|
||||
fn font_files(pdf: &[u8]) -> Vec<Vec<u8>> {
|
||||
let pdf = hayro_syntax::Pdf::new(std::sync::Arc::new(pdf.to_vec())).unwrap();
|
||||
pdf.objects()
|
||||
.into_iter()
|
||||
.filter_map(|o| match o {
|
||||
Object::Dict(d) => d.get::<Stream<'_>>(b"FontFile2"),
|
||||
_ => None,
|
||||
})
|
||||
.map(|s| s.decoded().unwrap().to_vec())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(tag, offset, length, checksum)` of each table in the sfnt `font`.
|
||||
fn tables(font: &[u8]) -> Vec<([u8; 4], usize, usize, u32)> {
|
||||
let be32 = |at: usize| u32::from_be_bytes(font[at..at + 4].try_into().unwrap());
|
||||
let n = usize::from(u16::from_be_bytes(font[4..6].try_into().unwrap()));
|
||||
(0..n)
|
||||
.map(|k| (font[12 + 16 * k..16 + 16 * k].try_into().unwrap(), be32(20 + 16 * k) as usize, be32(24 + 16 * k) as usize, be32(16 + 16 * k)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn checksum(data: &[u8]) -> u32 {
|
||||
data.chunks(4).fold(0u32, |sum, c| {
|
||||
let mut word = [0u8; 4];
|
||||
word[..c.len()].copy_from_slice(c);
|
||||
sum.wrapping_add(u32::from_be_bytes(word))
|
||||
})
|
||||
}
|
||||
|
||||
fn table<'a>(font: &'a [u8], tag: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
tables(font).into_iter().find(|t| &t.0 == tag).map(|(_, off, len, _)| &font[off..off + len])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_truetype_subset_keeps_the_os2_table_and_its_fstype() {
|
||||
let face = ttf_parser::Face::parse(TINY_GLYF, 0).unwrap();
|
||||
let text: String = ('!'..='~').filter(|c| face.glyph_index(*c).is_some()).collect();
|
||||
assert!(!text.is_empty(), "the fixture maps some ASCII");
|
||||
let files = font_files(&document(TINY_GLYF, &text));
|
||||
assert_eq!(files.len(), 1, "one embedded TrueType program");
|
||||
let sub = &files[0];
|
||||
let os2 = table(sub, b"OS/2").expect("the subset carries an OS/2 table");
|
||||
assert_eq!(os2, table(TINY_GLYF, b"OS/2").unwrap(), "the source's OS/2, unchanged");
|
||||
assert_eq!(u16::from_be_bytes(os2[8..10].try_into().unwrap()), 4, "fsType: Preview & Print");
|
||||
// A valid sfnt: tags in order, each table's checksum right, the whole font summing to the
|
||||
// magic number through head.checkSumAdjustment.
|
||||
let t = tables(sub);
|
||||
assert!(
|
||||
t.windows(2).all(|w| w[0].0 < w[1].0),
|
||||
"tables in tag order: {:?}",
|
||||
t.iter().map(|t| String::from_utf8_lossy(&t.0).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
for (tag, off, len, sum) in &t {
|
||||
let mut data = sub[*off..off + len].to_vec();
|
||||
if tag == b"head" {
|
||||
data[8..12].fill(0);
|
||||
}
|
||||
assert_eq!(checksum(&data), *sum, "{} checksum", String::from_utf8_lossy(tag));
|
||||
assert_eq!(off % 4, 0, "{} is 4-byte aligned", String::from_utf8_lossy(tag));
|
||||
}
|
||||
assert_eq!(checksum(sub), 0xB1B0_AFBA, "whole-font checksum");
|
||||
// And it still reads as a font with the glyphs it was given.
|
||||
let reread = ttf_parser::Face::parse(sub, 0).unwrap();
|
||||
// A subset, not the source font embedded whole: of the fixture's 11 glyphs, .notdef and the
|
||||
// ones the text uses, and only their outlines.
|
||||
assert!(
|
||||
2 <= reread.number_of_glyphs() && reread.number_of_glyphs() < face.number_of_glyphs(),
|
||||
"{} of {} glyphs",
|
||||
reread.number_of_glyphs(),
|
||||
face.number_of_glyphs()
|
||||
);
|
||||
assert!(table(sub, b"glyf").unwrap().len() < table(TINY_GLYF, b"glyf").unwrap().len(), "only the used glyphs' outlines");
|
||||
assert_eq!(reread.permissions(), face.permissions());
|
||||
}
|
||||
Vendored
+17
@@ -27,6 +27,7 @@ itself omits those texts. Its examples and four CC0 ICC profiles are retained wi
|
||||
| `patches/15-pdfx-conformance.patch` | PDF/X conformance key | `serialize.rs` |
|
||||
| `patches/16-modification-date.patch` | Modification date | `interchange/metadata.rs` |
|
||||
| `patches/17-color-precision.patch` | Color precision | `graphics/{color,shading_function}.rs` |
|
||||
| `patches/18-subset-os2.patch` | Embedding permissions in TrueType subsets | `text/cid.rs` |
|
||||
|
||||
Each patch applies with `patch -p1` from this directory, in numeric order, to the pristine
|
||||
crate. `SOURCE.json` records, for every retained file, the upstream hash (`upstream_sha256`)
|
||||
@@ -434,3 +435,19 @@ as a multiple of 1/255: C75 becomes 0.7490196 and C4 M4 Y4 K4 becomes 0.03921568
|
||||
|
||||
Tests: `crates/pdf/tests/print_color.rs` (`f32_colors_*`, `gray_stops_keep_their_exact_black_in_cmyk_shadings`,
|
||||
`mesh_*`, `rgb_mesh_is_a_device_rgb_shading`).
|
||||
|
||||
## 18: embedding permissions in TrueType subsets
|
||||
|
||||
The subsetter writes only the tables it subsets (`glyf`/`loca`, `head`, `hmtx`, `maxp`, `name`,
|
||||
`post`, and `cvt `, `fpgm`, `prep` unchanged), so a `FontFile2` program had no `OS/2` table and
|
||||
the font's embedding permissions (`OS/2.fsType`) never reached the PDF: a Preview & Print or
|
||||
Restricted font looked unrestricted to anything reading the file later (storytold/designcraft#1781).
|
||||
|
||||
`CIDFont` now adds the source font's `OS/2` table, byte for byte, to a TrueType subset that lacks
|
||||
one. `with_table` rewrites the table directory in tag order with its binary-search fields,
|
||||
aligns each table to four bytes with its checksum, and recomputes `head.checkSumAdjustment`. A
|
||||
font with no `OS/2` is embedded as before. CFF programs are still embedded bare (`FontFile3`
|
||||
`/CIDFontType0C`), which has no table directory to carry it.
|
||||
|
||||
craft-pdf's `font_tables` test embeds a TrueType font with `fsType` 4 and checks the program's
|
||||
`OS/2` matches the source, its table order and checksums, and that it still parses.
|
||||
Vendored
+1
-1
@@ -332,7 +332,7 @@
|
||||
{
|
||||
"path": "src/text/cid.rs",
|
||||
"upstream_sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2",
|
||||
"sha256": "5332282160b75e2a24345b546ead219476768829bef881562f5658dd50ce97f2"
|
||||
"sha256": "4bb482edddaf1cac916cb110e8139c90c53582f0c1c7afc6fa1cf6b037ccae5e"
|
||||
},
|
||||
{
|
||||
"path": "src/text/font.rs",
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
diff --git a/src/text/cid.rs b/src/text/cid.rs
|
||||
index 2c52127..dbd4e37 100644
|
||||
--- a/src/text/cid.rs
|
||||
+++ b/src/text/cid.rs
|
||||
@@ -238,6 +238,8 @@ impl CIDFont {
|
||||
let num_glyphs = subsetted.num_glyphs();
|
||||
let subsetted_data = subsetted.font_data().0;
|
||||
|
||||
+ // Outlives the stream, which may borrow it.
|
||||
+ let with_os2;
|
||||
let font_stream = {
|
||||
let mut data = subsetted_data.as_ref().as_ref();
|
||||
|
||||
@@ -248,6 +250,14 @@ impl CIDFont {
|
||||
|
||||
if let Some(cff) = subsetted_ref.data_for_tag(Cff::TAG) {
|
||||
data = cff.as_bytes();
|
||||
+ } else if let Some(os2) = self.font.font_ref().data_for_tag(OS2_TAG) {
|
||||
+ // The subsetter writes no `OS/2`, so the font's embedding permissions
|
||||
+ // (`fsType`) would not reach the PDF: a Preview & Print or Restricted font
|
||||
+ // would look unrestricted to what reads it later. Keep the source's table.
|
||||
+ if let Some(font) = with_table(data, OS2_TAG.to_be_bytes(), os2.as_bytes()) {
|
||||
+ with_os2 = font;
|
||||
+ data = &with_os2;
|
||||
+ }
|
||||
}
|
||||
|
||||
FilterStreamBuilder::new_from_binary_data(data).finish(&sc.serialize_settings())
|
||||
@@ -433,6 +443,75 @@ pub(crate) fn base_font_name<T: Hash>(font: &Font, data: &T) -> String {
|
||||
format!("{subset_tag}+{trimmed}")
|
||||
}
|
||||
|
||||
+const OS2_TAG: skrifa::raw::types::Tag = skrifa::raw::types::Tag::new(b"OS/2");
|
||||
+
|
||||
+/// The sfnt `font` with `table` added under `tag`, or `None` when `font` can't be read as one or
|
||||
+/// already has that table. The table directory is written in tag order with its binary-search
|
||||
+/// fields, every table 4-byte aligned with its checksum, and `head.checkSumAdjustment`
|
||||
+/// recomputed (OpenType, "Table directory" and "Calculating checksums").
|
||||
+fn with_table(font: &[u8], tag: [u8; 4], table: &[u8]) -> Option<Vec<u8>> {
|
||||
+ let be16 = |at: usize| Some(u16::from_be_bytes(font.get(at..at + 2)?.try_into().ok()?));
|
||||
+ let be32 = |at: usize| Some(u32::from_be_bytes(font.get(at..at + 4)?.try_into().ok()?));
|
||||
+ let count = usize::from(be16(4)?);
|
||||
+ let mut tables: Vec<([u8; 4], &[u8])> = Vec::with_capacity(count + 1);
|
||||
+ for k in 0..count {
|
||||
+ let record = 12 + 16 * k;
|
||||
+ let t: [u8; 4] = font.get(record..record + 4)?.try_into().ok()?;
|
||||
+ let (offset, length) = (be32(record + 8)? as usize, be32(record + 12)? as usize);
|
||||
+ tables.push((t, font.get(offset..offset.checked_add(length)?)?));
|
||||
+ }
|
||||
+ if tables.iter().any(|(t, _)| *t == tag) {
|
||||
+ return None;
|
||||
+ }
|
||||
+ tables.push((tag, table));
|
||||
+ tables.sort_by_key(|(t, _)| *t);
|
||||
+ let n = u16::try_from(tables.len()).ok()?;
|
||||
+ let entry_selector = 15 - n.leading_zeros() as u16;
|
||||
+ let search_range = (1u16 << entry_selector).checked_mul(16)?;
|
||||
+ let range_shift = n.checked_mul(16)?.checked_sub(search_range)?;
|
||||
+ let checksum = |data: &[u8]| {
|
||||
+ data.chunks(4).fold(0u32, |sum, c| {
|
||||
+ let mut word = [0u8; 4];
|
||||
+ word[..c.len()].copy_from_slice(c);
|
||||
+ sum.wrapping_add(u32::from_be_bytes(word))
|
||||
+ })
|
||||
+ };
|
||||
+ let mut out = Vec::with_capacity(font.len() + table.len() + 16 + 3);
|
||||
+ out.extend_from_slice(font.get(0..4)?);
|
||||
+ for v in [n, search_range, entry_selector, range_shift] {
|
||||
+ out.extend_from_slice(&v.to_be_bytes());
|
||||
+ }
|
||||
+ let mut offset = 12 + 16 * tables.len();
|
||||
+ let mut head_at = None;
|
||||
+ for (t, data) in &tables {
|
||||
+ let mut data = data.to_vec();
|
||||
+ if t == b"head" {
|
||||
+ // checkSumAdjustment is zero while the checksums are taken
|
||||
+ data.get_mut(8..12)?.fill(0);
|
||||
+ head_at = Some(offset);
|
||||
+ }
|
||||
+ out.extend_from_slice(t);
|
||||
+ out.extend_from_slice(&checksum(&data).to_be_bytes());
|
||||
+ out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes());
|
||||
+ out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes());
|
||||
+ offset = offset.checked_add(data.len().next_multiple_of(4))?;
|
||||
+ }
|
||||
+ for (t, data) in &tables {
|
||||
+ let start = out.len();
|
||||
+ out.extend_from_slice(data);
|
||||
+ if t == b"head" {
|
||||
+ out.get_mut(start + 8..start + 12)?.fill(0);
|
||||
+ }
|
||||
+ out.resize(out.len().next_multiple_of(4), 0);
|
||||
+ }
|
||||
+ if let Some(at) = head_at {
|
||||
+ let adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
|
||||
+ out.get_mut(at + 8..at + 12)?
|
||||
+ .copy_from_slice(&adjustment.to_be_bytes());
|
||||
+ }
|
||||
+ Some(out)
|
||||
+}
|
||||
+
|
||||
#[cfg_attr(feature = "comemo", comemo::memoize)]
|
||||
fn subset_font(font: Font, glyph_remapper: &GlyphRemapper) -> KrillaResult<(Font, Rect)> {
|
||||
let mut bbox: Option<Rect> = None;
|
||||
Vendored
+79
@@ -238,6 +238,8 @@ impl CIDFont {
|
||||
let num_glyphs = subsetted.num_glyphs();
|
||||
let subsetted_data = subsetted.font_data().0;
|
||||
|
||||
// Outlives the stream, which may borrow it.
|
||||
let with_os2;
|
||||
let font_stream = {
|
||||
let mut data = subsetted_data.as_ref().as_ref();
|
||||
|
||||
@@ -248,6 +250,14 @@ impl CIDFont {
|
||||
|
||||
if let Some(cff) = subsetted_ref.data_for_tag(Cff::TAG) {
|
||||
data = cff.as_bytes();
|
||||
} else if let Some(os2) = self.font.font_ref().data_for_tag(OS2_TAG) {
|
||||
// The subsetter writes no `OS/2`, so the font's embedding permissions
|
||||
// (`fsType`) would not reach the PDF: a Preview & Print or Restricted font
|
||||
// would look unrestricted to what reads it later. Keep the source's table.
|
||||
if let Some(font) = with_table(data, OS2_TAG.to_be_bytes(), os2.as_bytes()) {
|
||||
with_os2 = font;
|
||||
data = &with_os2;
|
||||
}
|
||||
}
|
||||
|
||||
FilterStreamBuilder::new_from_binary_data(data).finish(&sc.serialize_settings())
|
||||
@@ -433,6 +443,75 @@ pub(crate) fn base_font_name<T: Hash>(font: &Font, data: &T) -> String {
|
||||
format!("{subset_tag}+{trimmed}")
|
||||
}
|
||||
|
||||
const OS2_TAG: skrifa::raw::types::Tag = skrifa::raw::types::Tag::new(b"OS/2");
|
||||
|
||||
/// The sfnt `font` with `table` added under `tag`, or `None` when `font` can't be read as one or
|
||||
/// already has that table. The table directory is written in tag order with its binary-search
|
||||
/// fields, every table 4-byte aligned with its checksum, and `head.checkSumAdjustment`
|
||||
/// recomputed (OpenType, "Table directory" and "Calculating checksums").
|
||||
fn with_table(font: &[u8], tag: [u8; 4], table: &[u8]) -> Option<Vec<u8>> {
|
||||
let be16 = |at: usize| Some(u16::from_be_bytes(font.get(at..at + 2)?.try_into().ok()?));
|
||||
let be32 = |at: usize| Some(u32::from_be_bytes(font.get(at..at + 4)?.try_into().ok()?));
|
||||
let count = usize::from(be16(4)?);
|
||||
let mut tables: Vec<([u8; 4], &[u8])> = Vec::with_capacity(count + 1);
|
||||
for k in 0..count {
|
||||
let record = 12 + 16 * k;
|
||||
let t: [u8; 4] = font.get(record..record + 4)?.try_into().ok()?;
|
||||
let (offset, length) = (be32(record + 8)? as usize, be32(record + 12)? as usize);
|
||||
tables.push((t, font.get(offset..offset.checked_add(length)?)?));
|
||||
}
|
||||
if tables.iter().any(|(t, _)| *t == tag) {
|
||||
return None;
|
||||
}
|
||||
tables.push((tag, table));
|
||||
tables.sort_by_key(|(t, _)| *t);
|
||||
let n = u16::try_from(tables.len()).ok()?;
|
||||
let entry_selector = 15 - n.leading_zeros() as u16;
|
||||
let search_range = (1u16 << entry_selector).checked_mul(16)?;
|
||||
let range_shift = n.checked_mul(16)?.checked_sub(search_range)?;
|
||||
let checksum = |data: &[u8]| {
|
||||
data.chunks(4).fold(0u32, |sum, c| {
|
||||
let mut word = [0u8; 4];
|
||||
word[..c.len()].copy_from_slice(c);
|
||||
sum.wrapping_add(u32::from_be_bytes(word))
|
||||
})
|
||||
};
|
||||
let mut out = Vec::with_capacity(font.len() + table.len() + 16 + 3);
|
||||
out.extend_from_slice(font.get(0..4)?);
|
||||
for v in [n, search_range, entry_selector, range_shift] {
|
||||
out.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
let mut offset = 12 + 16 * tables.len();
|
||||
let mut head_at = None;
|
||||
for (t, data) in &tables {
|
||||
let mut data = data.to_vec();
|
||||
if t == b"head" {
|
||||
// checkSumAdjustment is zero while the checksums are taken
|
||||
data.get_mut(8..12)?.fill(0);
|
||||
head_at = Some(offset);
|
||||
}
|
||||
out.extend_from_slice(t);
|
||||
out.extend_from_slice(&checksum(&data).to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(offset).ok()?.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(data.len()).ok()?.to_be_bytes());
|
||||
offset = offset.checked_add(data.len().next_multiple_of(4))?;
|
||||
}
|
||||
for (t, data) in &tables {
|
||||
let start = out.len();
|
||||
out.extend_from_slice(data);
|
||||
if t == b"head" {
|
||||
out.get_mut(start + 8..start + 12)?.fill(0);
|
||||
}
|
||||
out.resize(out.len().next_multiple_of(4), 0);
|
||||
}
|
||||
if let Some(at) = head_at {
|
||||
let adjustment = 0xB1B0_AFBAu32.wrapping_sub(checksum(&out));
|
||||
out.get_mut(at + 8..at + 12)?
|
||||
.copy_from_slice(&adjustment.to_be_bytes());
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "comemo", comemo::memoize)]
|
||||
fn subset_font(font: Font, glyph_remapper: &GlyphRemapper) -> KrillaResult<(Font, Rect)> {
|
||||
let mut bbox: Option<Rect> = None;
|
||||
|
||||
Reference in new issue
Block a user