mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 08:50:02 -06:00
* Fonts embedded in a .docx lay out and export its text (fixes #1171) * Fonts: forget lookups made while an embedded family was missing (#1171)
173 lines
9.8 KiB
Rust
173 lines
9.8 KiB
Rust
//! Fonts added at run time (#582): `font.add`, `font.list`, `font.remove`.
|
|
|
|
use serde_json::json;
|
|
use wordcraft_doc::Pos;
|
|
|
|
use crate::Session;
|
|
use crate::cmd::insert::base64_encode;
|
|
|
|
/// The bundled face `file` of family `original` renamed `family` (as long as `original`), so it
|
|
/// is a font the database doesn't have.
|
|
fn renamed(file: &str, original: &str, family: &str) -> Vec<u8> {
|
|
assert_eq!(family.len(), original.len());
|
|
let mut data = std::fs::read(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap();
|
|
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
|
for (from, to) in [(utf16(original), utf16(family)), (original.as_bytes().to_vec(), family.as_bytes().to_vec())] {
|
|
let mut i = 0;
|
|
while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) {
|
|
data[i + at..i + at + to.len()].copy_from_slice(&to);
|
|
i += at + to.len();
|
|
}
|
|
}
|
|
data
|
|
}
|
|
|
|
/// The caret's x after `text` set in `font`.
|
|
fn text_end_x(s: &mut Session, font: &str, text: &str) -> f32 {
|
|
s.run("document.setText", &json!({"text": text})).unwrap();
|
|
s.run("select.all", &json!({})).unwrap();
|
|
s.run("format.font", &json!({"name": font})).unwrap();
|
|
s.layout().caret_on(&Pos::body(0, text.len()), 0).unwrap().x
|
|
}
|
|
|
|
#[test]
|
|
fn an_added_font_is_listed_and_lays_out_text_already_set_in_it() {
|
|
// A monospaced face, so it measures unlike whatever proportional font the system falls back
|
|
// to (Source Serif 4 itself where no system fonts are installed, as on the FreeBSD runner).
|
|
// The name has no Serif/Sans/Mono in it, which would pick a fallback of that kind.
|
|
const FAMILY: &str = "Cmdfont Face 4";
|
|
// Narrow letters: set monospaced they take far more room than in any proportional font.
|
|
const TEXT: &str = "illicit little lilies fill tiny jill";
|
|
let mut s = Session::new(wordcraft_doc::Document::new());
|
|
let mono = text_end_x(&mut s, "JetBrains Mono", TEXT);
|
|
// Not installed yet: the text falls back to another font.
|
|
let missing = text_end_x(&mut s, FAMILY, TEXT);
|
|
assert!((missing - mono).abs() > 1.0, "the fallback measures differently: {missing} vs {mono}");
|
|
|
|
let data = base64_encode(&renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", FAMILY));
|
|
let r = s.run("font.add", &json!({"data": data, "name": "CmdfontFace4-Regular.ttf"})).unwrap();
|
|
assert_eq!(r["families"], json!([FAMILY]));
|
|
assert_eq!(r["faces"][0]["style"], "Regular");
|
|
// The text already set in it re-lays out with it, without touching the document.
|
|
let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
|
assert!((x - mono).abs() < 0.01, "measured with the added font: {x} vs {mono} (missing {missing})");
|
|
|
|
let list = s.run("font.list", &json!({"all": true})).unwrap();
|
|
assert!(list["fonts"].as_array().unwrap().iter().any(|f| f["families"] == json!([FAMILY])));
|
|
assert!(list["families"].as_array().unwrap().iter().any(|f| f == FAMILY));
|
|
// It exports to PDF like any other font.
|
|
assert!(crate::io::save_bytes("out.pdf", &s.doc).unwrap().starts_with(b"%PDF"));
|
|
|
|
// Adding it again changes nothing.
|
|
let again = s.run("font.add", &json!({"data": data, "name": "again.ttf"}));
|
|
assert!(again.unwrap_err().to_string().contains("already available"));
|
|
|
|
let removed = s.run("font.remove", &json!({"family": FAMILY})).unwrap();
|
|
assert_eq!(removed["removed"].as_array().unwrap().len(), 1);
|
|
let x = s.layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
|
assert!((x - missing).abs() < 0.01, "falls back again once removed");
|
|
assert!(s.run("font.remove", &json!({"family": FAMILY})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn adding_a_font_never_changes_text_that_does_not_use_it() {
|
|
const FAMILY: &str = "Metric Serif 4";
|
|
let mut s = Session::new(crate::sample::sample_document());
|
|
let before = s.layout();
|
|
let positions = |s: &mut Session| {
|
|
let l = s.layout();
|
|
(0..s.doc.body.len().min(12)).filter_map(|b| l.caret_on(&Pos::body(b, 0), 0).map(|c| (c.page, c.x, c.top))).collect::<Vec<_>>()
|
|
};
|
|
let a = positions(&mut s);
|
|
s.run("font.add", &json!({"data": base64_encode(&renamed("SourceSerif4-Bold.ttf", "Source Serif 4", FAMILY)), "name": "m.ttf"})).unwrap();
|
|
assert_eq!(positions(&mut s), a);
|
|
assert_eq!(s.layout().pages.len(), before.pages.len());
|
|
}
|
|
|
|
#[test]
|
|
fn bad_font_input_is_an_error() {
|
|
let mut s = Session::new(wordcraft_doc::Document::new());
|
|
for p in [
|
|
json!({"data": base64_encode(b"definitely not a font")}),
|
|
json!({"data": "%%% not base64"}),
|
|
json!({"data": base64_encode(b"wOF2\0\x01\0\0\xff\xff\xff\xff")}),
|
|
json!({"path": "/nonexistent/font.ttf"}),
|
|
json!({"path": env!("CARGO_MANIFEST_DIR")}),
|
|
json!({"path": concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")}),
|
|
json!({"url": "https://fonts.googleapis.com/css2?family=Roboto"}),
|
|
] {
|
|
assert!(s.run("font.add", &p).is_err(), "{p}");
|
|
}
|
|
assert!(s.run("font.remove", &json!({})).is_err());
|
|
assert!(s.run("font.remove", &json!({"family": "Source Sans 3"})).is_err(), "the bundled fonts stay");
|
|
// No file: the UI is asked for a picker.
|
|
let r = s.run("font.add", &json!({})).unwrap();
|
|
assert_eq!(r["pending"], true);
|
|
assert!(s.ui_requests.iter().any(|r| r["open"] == "addFont"));
|
|
}
|
|
|
|
/// A .docx whose text is set in `family`, embedding `font` through the font table (#1171): as an
|
|
/// obfuscated `.odttf` when `key` is a real GUID, as a plain `.ttf` when it is all zeros.
|
|
fn docx_embedding(family: &str, font: Option<(&[u8], &str)>, text: &str) -> Vec<u8> {
|
|
const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
const R: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
|
const PR: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
|
let rels = |items: &[(&str, &str)]| {
|
|
let list: String = items.iter().map(|(t, g)| format!(r#"<Relationship Id="rId{t}" Type="{R}/{t}" Target="{g}"/>"#)).collect();
|
|
format!(r#"<?xml version="1.0" encoding="UTF-8"?><Relationships xmlns="{PR}">{list}</Relationships>"#)
|
|
};
|
|
let mut files: Vec<(String, Vec<u8>)> = vec![
|
|
(
|
|
"[Content_Types].xml".into(),
|
|
br#"<?xml version="1.0" encoding="UTF-8"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="ttf" ContentType="application/x-font-ttf"/><Default Extension="odttf" ContentType="application/vnd.openxmlformats-officedocument.obfuscatedFont"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/></Types>"#.to_vec(),
|
|
),
|
|
("_rels/.rels".into(), rels(&[("officeDocument", "word/document.xml")]).into_bytes()),
|
|
("word/_rels/document.xml.rels".into(), rels(&[("fontTable", "fontTable.xml")]).into_bytes()),
|
|
(
|
|
"word/document.xml".into(),
|
|
format!(r#"<w:document xmlns:w="{W}"><w:body><w:p><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}"/><w:sz w:val="20"/></w:rPr><w:t>{text}</w:t></w:r></w:p></w:body></w:document>"#).into_bytes(),
|
|
),
|
|
];
|
|
let mut table = format!(r#"<w:fonts xmlns:w="{W}" xmlns:r="{R}"><w:font w:name="{family}">"#);
|
|
if let Some((data, key)) = font {
|
|
let digits = key.replace(['{', '}', '-'], "");
|
|
let hex: Vec<u8> = (0..16).map(|i| u8::from_str_radix(&digits[i * 2..i * 2 + 2], 16).unwrap()).collect();
|
|
let zero = hex.iter().all(|b| *b == 0);
|
|
let mut data = data.to_vec();
|
|
// Obfuscation (ECMA-376 Part 1, 17.8.1): the first 32 bytes XOR the key's bytes, reversed.
|
|
for i in 0..32 {
|
|
data[i] ^= hex[15 - i % 16];
|
|
}
|
|
let ext = if zero { "ttf" } else { "odttf" };
|
|
table.push_str(&format!(r#"<w:embedRegular r:id="rIdfont" w:fontKey="{key}" w:subsetted="0"/>"#));
|
|
files.push(("word/_rels/fontTable.xml.rels".into(), rels(&[("font", &format!("fonts/font1.{ext}"))]).into_bytes()));
|
|
files.push((format!("word/fonts/font1.{ext}"), data));
|
|
}
|
|
table.push_str("</w:font></w:fonts>");
|
|
files.push(("word/fontTable.xml".into(), table.into_bytes()));
|
|
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
|
for (name, bytes) in files {
|
|
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
|
std::io::Write::write_all(&mut zip, &bytes).unwrap();
|
|
}
|
|
zip.finish().unwrap().into_inner()
|
|
}
|
|
|
|
#[test]
|
|
fn fonts_embedded_in_a_docx_lay_out_its_text() {
|
|
const TEXT: &str = "illicit little lilies fill tiny jill";
|
|
let end_x =
|
|
|bytes: &[u8]| Session::new(crate::io::open_bytes("e.docx", bytes).unwrap()).layout().caret_on(&Pos::body(0, TEXT.len()), 0).unwrap().x;
|
|
let mono = end_x(&docx_embedding("JetBrains Mono", None, TEXT));
|
|
// Both forms Word and other producers write: an obfuscated .odttf with its key, a plain .ttf
|
|
// with the all-zero key. Each is a family no other test or system has.
|
|
for (family, key) in [("JetBrains Odtt", "{9F1C2A4E-7B3D-4C58-A1E6-0D2B8F5C3E77}"), ("JetBrains Zero", "{00000000-0000-0000-0000-000000000000}")]
|
|
{
|
|
let font = renamed("JetBrainsMono-Regular.ttf", "JetBrains Mono", family);
|
|
let missing = end_x(&docx_embedding(family, None, TEXT));
|
|
assert!((missing - mono).abs() > 1.0, "{family}: not embedded, the text falls back: {missing} vs {mono}");
|
|
let x = end_x(&docx_embedding(family, Some((&font, key)), TEXT));
|
|
assert!((x - mono).abs() < 0.01, "{family}: measured with the embedded font: {x} vs {mono} (missing {missing})");
|
|
}
|
|
}
|