use std::collections::{BTreeSet, HashMap};
use std::ffi::CString;
use skia_safe::pdf::{self, DateTime, Metadata, Outline, StructureElementNode, node_id};
use crate::docx::{Block, Document, Paragraph};
use crate::layout::fragment::Item;
use crate::layout::{Snapshot, traverse};
use crate::render::Renderer;
struct Elem {
kind: String,
id: i32,
key: Option<Vec<usize>>,
kids: Vec<Elem>,
}
impl Elem {
fn new(kind: &str, id: i32, key: Option<Vec<usize>>) -> Elem {
Elem { kind: kind.into(), id, key, kids: Vec::new() }
}
fn child(&mut self, kind: &str, key: Option<&[usize]>, next: &mut i32) -> &mut Elem {
let reuse = key.is_some() && self.kids.last().is_some_and(|k| k.kind == kind && k.key.as_deref() == key);
if !reuse {
*next += 1;
self.kids.push(Elem::new(kind, *next, key.map(<[usize]>::to_vec)));
}
self.kids.last_mut().unwrap()
}
fn into_node<'a>(self, th: &'a (CString, CString, CString)) -> StructureElementNode<'a> {
let mut n = StructureElementNode::new(&self.kind);
n.set_node_id(self.id);
if self.kind == "Figure" {
n.set_alt("Picture");
}
if self.kind == "TH" {
n.attributes_mut().append_name(&th.0, &th.1, &th.2);
}
for k in self.kids {
n.append_child(k.into_node(th));
}
n
}
}
fn body_paragraphs(doc: &Document) -> Vec<(Vec<usize>, Vec<bool>, &Paragraph)> {
fn walk<'d>(blocks: &'d [Block], path: &mut Vec<usize>, header: &mut Vec<bool>, out: &mut Vec<(Vec<usize>, Vec<bool>, &'d Paragraph)>) {
for (k, b) in blocks.iter().enumerate() {
path.push(k);
match b {
Block::Para(p) => out.push((path.clone(), header.clone(), p)),
Block::Table(t) => {
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
path.push(r);
path.push(c);
header.push(row.header);
walk(&cell.blocks, path, header, out);
header.pop();
path.pop();
path.pop();
}
}
}
}
path.pop();
}
}
let mut out = Vec::new();
for (i, s) in doc.sections.iter().enumerate() {
walk(&s.blocks, &mut vec![i], &mut Vec::new(), &mut out);
}
out
}
pub fn export(doc: &Document, snapshot: &Snapshot, title: &str) -> Vec<u8> {
let paras = body_paragraphs(doc);
let index: HashMap<u64, usize> = paras.iter().enumerate().map(|(i, p)| (p.2.id, i)).collect();
let pages: Vec<Vec<Item>> = snapshot.pages().iter().map(|p| traverse::paint_items(p)).collect();
let n = paras.len() as i32;
let mut drawn = vec![false; paras.len()];
let mut figures: Vec<(Option<usize>, i32)> = Vec::new();
let mut ids: Vec<Vec<i32>> = Vec::new();
let mut last = None;
for items in &pages {
let mut page_ids = Vec::with_capacity(items.len());
for item in items {
page_ids.push(match item {
Item::Glyphs(r) => match r.para.and_then(|p| index.get(&p.0)) {
Some(&i) if r.chars.iter().any(|c| c.2.is_some() && !c.0.is_whitespace()) => {
drawn[i] = true;
last = Some(i);
i as i32 + 1
}
Some(&i) if drawn[i] => i as i32 + 1,
Some(_) => node_id::OTHER_ARTIFACT,
None => node_id::PAGINATION_ARTIFACT,
},
Item::Rect { .. } | Item::Gridline { .. } => node_id::LAYOUT_ARTIFACT,
Item::Image { .. } => {
figures.push((last, n + 1 + figures.len() as i32));
figures.last().unwrap().1
}
});
}
ids.push(page_ids);
}
let mut next = n + figures.len() as i32;
let mut root = Elem::new("Document", 0, None);
for (i, (path, header, p)) in paras.iter().enumerate() {
if !drawn[i] && !figures.iter().any(|f| f.0 == Some(i)) {
continue;
}
let mut c = &mut root;
let mut k = 1;
let mut step = 0;
while k + 3 < path.len() {
c = c.child("Table", Some(&path[..=k]), &mut next);
c = c.child("TR", Some(&path[..=k + 1]), &mut next);
c = c.child(if header[step] { "TH" } else { "TD" }, Some(&path[..=k + 2]), &mut next);
k += 3;
step += 1;
}
let kind = match p.style.outline_lvl {
Some(l @ 0..6) => format!("H{}", l + 1),
_ => "P".into(),
};
let mut e = Elem::new(&kind, i as i32 + 1, None);
if kind == "P" && p.list.is_some() {
let li = c.child("L", Some(&[][..]), &mut next).child("LI", None, &mut next);
e.kind = "LBody".into();
li.kids.push(e);
c = li;
} else {
c.kids.push(e);
}
let e = c.kids.last_mut().unwrap();
e.kids.extend(figures.iter().filter(|f| f.0 == Some(i)).map(|f| Elem::new("Figure", f.1, None)));
}
root.id = next + 1;
root.kids.extend(figures.iter().filter(|f| f.0.is_none()).map(|f| Elem::new("Figure", f.1, None)));
let th = (CString::new("Table").unwrap(), CString::new("Scope").unwrap(), CString::new("Column").unwrap());
let lang = paras.first().map(|p| p.2.mark.lang.clone()).filter(|l| !l.is_empty());
let metadata = Metadata {
title: if title.trim().is_empty() { "Document".into() } else { title.into() },
creator: "Twip".into(),
producer: "Twip".into(),
creation: Some(now()),
lang: lang.unwrap_or_else(|| "en-US".into()),
pdf_a: true, structure_element_tree_root: Some(root.into_node(&th)),
outline: Outline::StructureElementHeaders,
..Default::default()
};
let mut out = Vec::new();
{
let mut pdoc = pdf::new_document(&mut out, Some(&metadata));
let mut renderer = Renderer::default();
renderer.with_text = true;
let mut used: HashMap<(usize, u32), (&crate::font::Face, BTreeSet<u16>)> = HashMap::new();
for item in pages.iter().flatten() {
if let Item::Glyphs(r) = item {
let key = (std::sync::Arc::as_ptr(&r.face.data) as usize, r.face.index);
used.entry(key).or_insert_with(|| (&*r.face, BTreeSet::new())).1.extend(r.glyphs.iter().map(|g| g.0 as u16));
}
}
for (face, glyphs) in used.values() {
if let Some(data) = subset(&face.data, face.index, glyphs) {
renderer.set_face_data(face, &data);
}
}
for ((page, items), page_ids) in snapshot.pages().iter().zip(&pages).zip(&ids) {
let mut on_page = pdoc.begin_page((page.w as f32, page.h as f32), None);
let canvas = on_page.canvas();
let mut current = node_id::NOTHING;
for (item, &id) in items.iter().zip(page_ids) {
if id != current {
pdf::set_node_id(canvas, id);
current = id;
}
renderer.draw_item(canvas, item, &doc.images);
}
pdoc = on_page.end_page();
}
pdoc.close();
}
out
}
fn subset(data: &[u8], index: u32, used: &BTreeSet<u16>) -> Option<Vec<u8>> {
let be16 = |b: &[u8], o: usize| b.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]]));
let be32 = |b: &[u8], o: usize| b.get(o..o + 4).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]));
let base = if data.get(..4)? == b"ttcf" { be32(data, 12 + 4 * index as usize)? as usize } else { 0 };
let mut tables: HashMap<[u8; 4], &[u8]> = HashMap::new();
for i in 0..be16(data, base + 4)? as usize {
let r = base + 12 + 16 * i;
let (off, len) = (be32(data, r + 8)? as usize, be32(data, r + 12)? as usize);
tables.insert(data.get(r..r + 4)?.try_into().ok()?, data.get(off..off + len)?);
}
let (head, maxp, loca, glyf) = (*tables.get(b"head")?, *tables.get(b"maxp")?, *tables.get(b"loca")?, *tables.get(b"glyf")?);
let long = be16(head, 50)? != 0;
let count = be16(maxp, 4)? as usize;
let at = |g: usize| if long { be32(loca, 4 * g).map(|o| o as usize) } else { be16(loca, 2 * g).map(|o| 2 * o as usize) };
let outline = |g: usize| glyf.get(at(g)?..at(g + 1)?);
let mut keep: BTreeSet<u16> = used.iter().copied().filter(|&g| (g as usize) < count).collect();
keep.insert(0);
let mut todo: Vec<u16> = keep.iter().copied().collect();
while let Some(g) = todo.pop() {
let o = outline(g as usize)?;
if o.len() < 10 || be16(o, 0)? as i16 >= 0 {
continue;
}
let mut p = 10;
loop {
let (flags, component) = (be16(o, p)?, be16(o, p + 2)?);
if (component as usize) < count && keep.insert(component) {
todo.push(component);
}
p += 4 + if flags & 1 != 0 { 4 } else { 2 };
p += if flags & 0x80 != 0 {
8
} else if flags & 0x40 != 0 {
4
} else if flags & 0x08 != 0 {
2
} else {
0
};
if flags & 0x20 == 0 {
break;
}
}
}
let (mut new_glyf, mut new_loca) = (Vec::new(), Vec::with_capacity(4 * (count + 1)));
for g in 0..count {
new_loca.extend((new_glyf.len() as u32).to_be_bytes());
if keep.contains(&(g as u16)) {
new_glyf.extend_from_slice(outline(g)?);
new_glyf.resize(new_glyf.len().next_multiple_of(4), 0);
}
}
new_loca.extend((new_glyf.len() as u32).to_be_bytes());
let mut new_head = head.to_vec();
new_head.get_mut(8..12)?.fill(0); new_head.get_mut(50..52)?.copy_from_slice(&1u16.to_be_bytes()); let post = tables.get(b"post").filter(|p| p.len() >= 32).map(|p| [&0x0003_0000u32.to_be_bytes()[..], &p[4..32]].concat());
let mut out_tables: Vec<([u8; 4], Vec<u8>)> = vec![(*b"glyf", new_glyf), (*b"head", new_head), (*b"loca", new_loca)];
out_tables.extend(post.map(|p| (*b"post", p)));
for tag in [b"cmap", b"hhea", b"hmtx", b"maxp", b"name", b"OS/2"] {
out_tables.push((*tag, tables.get(tag)?.to_vec()));
}
out_tables.sort_by_key(|t| t.0);
let n = out_tables.len();
let selector = n.ilog2();
let mut out = Vec::new();
out.extend(0x0001_0000u32.to_be_bytes());
for v in [n as u16, 16 << selector, selector as u16, (n as u16) * 16 - (16 << selector)] {
out.extend(v.to_be_bytes());
}
let mut offset = 12 + 16 * n;
for (tag, body) in &out_tables {
let sum = body.chunks(4).fold(0u32, |s, c| {
let mut w = [0u8; 4];
w[..c.len()].copy_from_slice(c);
s.wrapping_add(u32::from_be_bytes(w))
});
out.extend(tag);
out.extend(sum.to_be_bytes());
out.extend((offset as u32).to_be_bytes());
out.extend((body.len() as u32).to_be_bytes());
offset += body.len().next_multiple_of(4);
}
for (_, body) in &out_tables {
out.extend(body);
out.resize(out.len().next_multiple_of(4), 0);
}
Some(out)
}
fn now() -> DateTime {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()) as i64;
let (days, rem) = (secs.div_euclid(86400), secs.rem_euclid(86400));
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
DateTime {
time_zone_minutes: 0,
year: year as u16,
month: month as u8,
day_of_week: (days + 4).rem_euclid(7) as u8,
day: day as u8,
hour: (rem / 3600) as u8,
minute: (rem / 60 % 60) as u8,
second: (rem % 60) as u8,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::Fonts;
#[test]
fn exports_fixtures() {
let fonts = Fonts::system();
let root = std::env::var_os("TWIP_REFERENCE_FIXTURES").map(std::path::PathBuf::from).unwrap_or_default();
let mut files: Vec<std::path::PathBuf> =
["T1/T1g_two_rows.docx", "L1/L1l_multilevel.docx", "P1/P1c_word_default.docx"].iter().map(|f| root.join(f)).collect();
files.push(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/editor/FX-STORIES.docx"));
for f in &files {
let Ok(bytes) = std::fs::read(f) else {
eprintln!("{} not found; skipped", f.display());
continue;
};
let mut e = crate::editing::Editor::open(&bytes, &fonts).unwrap();
e.complete(&fonts);
let pdf = export(e.document(), &e.snapshot(), "Fixture");
let text = String::from_utf8_lossy(&pdf);
assert!(pdf.starts_with(b"%PDF-") && text.contains("%%EOF"), "{}", f.display());
assert!(text.contains("/StructTreeRoot") && text.contains("/MarkInfo"), "{}: untagged", f.display());
assert!(
root.as_os_str().is_empty() || !f.starts_with(&root) || pdf.len() < 150_000,
"{}: {} bytes, fonts not subset",
f.display(),
pdf.len()
);
if let Ok(dir) = std::env::var("PDF_OUT") {
let name = f.file_stem().unwrap().to_string_lossy().into_owned();
std::fs::write(std::path::Path::new(&dir).join(format!("{name}.pdf")), &pdf).unwrap();
}
}
}
}