mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-05 18:19:50 +00:00
New modules: layout.rs (data types and layout analysis), as_html.rs, as_docx.rs, as_rtf.rs.
365 lines
20 KiB
Rust
365 lines
20 KiB
Rust
use super::as_html::esc;
|
|
use super::layout::{Image, Item, Page, Table, body_size, items};
|
|
use super::*;
|
|
|
|
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
|
|
pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] {
|
|
// The value is clamped to 0..=255 before the cast, so it cannot truncate.
|
|
c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 })
|
|
}
|
|
|
|
/// The colour as `RRGGBB`, or `None` for black (the default every format already uses).
|
|
pub(crate) fn hex(c: [f64; 3]) -> Option<String> {
|
|
let [r, g, b] = rgb8(c);
|
|
(r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}"))
|
|
}
|
|
|
|
/// A formatted text run (shared by paragraphs and table cells).
|
|
fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String {
|
|
let mut rpr = String::new();
|
|
if bold {
|
|
rpr.push_str("<w:b/>");
|
|
}
|
|
if italic {
|
|
rpr.push_str("<w:i/>");
|
|
}
|
|
if let Some(h) = hex(color) {
|
|
rpr.push_str(&format!("<w:color w:val=\"{h}\"/>"));
|
|
}
|
|
let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 };
|
|
rpr.push_str(&format!("<w:sz w:val=\"{half_points}\"/>"));
|
|
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
|
|
}
|
|
|
|
/// One Word table: explicit absent borders (alignment alone cannot prove PDF rules), a grid sized
|
|
/// from the detected column edges, `gridSpan` for spanning cells, empty cells for holes.
|
|
pub(crate) fn docx_table(t: &Table) -> String {
|
|
let mut edges = t.cols.clone();
|
|
edges.push(t.rect[2]);
|
|
let widths: Vec<i64> = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect();
|
|
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
|
|
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
|
|
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
|
|
}
|
|
s.push_str("</w:tblBorders></w:tblPr><w:tblGrid>");
|
|
for w in &widths {
|
|
s.push_str(&format!("<w:gridCol w:w=\"{w}\"/>"));
|
|
}
|
|
s.push_str("</w:tblGrid>");
|
|
for row in &t.rows {
|
|
s.push_str("<w:tr>");
|
|
let mut column = 0usize;
|
|
for c in row {
|
|
let span = if c.span > 1 { format!("<w:gridSpan w:val=\"{}\"/>", c.span) } else { String::new() };
|
|
let w: i64 = widths.iter().skip(column).take(c.span).sum();
|
|
s.push_str(&format!(
|
|
"<w:tc><w:tcPr><w:tcW w:w=\"{w}\" w:type=\"dxa\"/>{span}</w:tcPr><w:p>{}</w:p></w:tc>",
|
|
run_xml(&c.text, c.size, c.bold, c.italic, c.color)
|
|
));
|
|
column = column.saturating_add(c.span);
|
|
}
|
|
// Pad the grid so every row covers all columns (Word rejects short rows).
|
|
for _ in column..t.cols.len() {
|
|
s.push_str("<w:tc><w:tcPr><w:tcW w:w=\"0\" w:type=\"auto\"/></w:tcPr><w:p/></w:tc>");
|
|
}
|
|
s.push_str("</w:tr>");
|
|
}
|
|
s.push_str("</w:tbl>");
|
|
s
|
|
}
|
|
|
|
/// Pages with rotated text, or text that actually overlaps an image or an oversized
|
|
/// (watermark-like) text block, cannot be put in paragraph flow without changing their layout.
|
|
/// Keep those pages as editable, absolutely positioned text boxes. Every other page, including
|
|
/// one with a logo or a big title that sits clear of the text, flows as paragraphs and tables.
|
|
fn positioned(page: &Page) -> bool {
|
|
if page.blocks.iter().any(|b| b.rotation.abs() > 0.1) {
|
|
return true;
|
|
}
|
|
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
|
let overlays = page
|
|
.images
|
|
.iter()
|
|
.map(|im| (im.rect, None))
|
|
.chain(page.blocks.iter().enumerate().filter(|(_, b)| b.size >= 2.0 * body).map(|(i, b)| (b.rect, Some(i))));
|
|
// Bound the pairwise test on hostile pages (many overlays times many blocks): past the
|
|
// budget, keep the default flowing layout.
|
|
let mut budget: usize = 1_000_000;
|
|
for (r, own) in overlays {
|
|
for (i, b) in page.blocks.iter().enumerate() {
|
|
if budget == 0 {
|
|
return false;
|
|
}
|
|
budget -= 1;
|
|
if Some(i) != own && overlaps(r, b.rect) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Whether two [x0, y0, x1, y1] boxes share more than a hairline (0.5 pt each way); boxes that
|
|
/// only touch, or hostile non-finite coordinates, do not count.
|
|
fn overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
|
|
let w = a[2].min(b[2]) - a[0].max(b[0]);
|
|
let h = a[3].min(b[3]) - a[1].max(b[1]);
|
|
w.is_finite() && h.is_finite() && w > 0.5 && h > 0.5
|
|
}
|
|
|
|
/// Bound every value written into a shape's style, including hostile PDF coordinates.
|
|
fn layout_number(n: f64) -> f64 {
|
|
if n.is_finite() { n.clamp(-1584.0, 1584.0) } else { 0.0 }
|
|
}
|
|
|
|
/// Most images a Word export embeds, and most image bytes (the zip writer has 32-bit sizes and
|
|
/// a 16-bit entry count; a hostile PDF must not be able to overflow them).
|
|
pub(crate) const MAX_MEDIA: usize = 1000;
|
|
|
|
const MAX_MEDIA_BYTES: usize = 512 << 20;
|
|
|
|
/// Whether `im` can still be embedded: it has data, and the package stays within its limits.
|
|
fn room_for(media: &[(String, &Image)], im: &Image) -> bool {
|
|
!im.bytes.is_empty()
|
|
&& media.len() < MAX_MEDIA
|
|
&& media.iter().map(|(_, m)| m.bytes.len()).fold(im.bytes.len(), usize::saturating_add) <= MAX_MEDIA_BYTES
|
|
}
|
|
|
|
/// Points as EMU (12700 per point); non-finite values are 0.
|
|
fn emu(pt: f64) -> i64 {
|
|
if pt.is_finite() { (pt * 12700.0).round() as i64 } else { 0 }
|
|
}
|
|
|
|
/// The `a:graphic` of one picture (DrawingML), rotated by `rotation` degrees when given.
|
|
fn picture_xml(n: usize, name: &str, (cx, cy): (i64, i64), rotation: Option<f64>) -> String {
|
|
let rot = rotation.map_or(String::new(), |r| format!(" rot=\"{}\"", (r * 60000.0).round() as i64));
|
|
format!(
|
|
"<wp:docPr id=\"{n}\" name=\"Picture {n}\"/><wp:cNvGraphicFramePr/>\
|
|
<a:graphic xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"><a:graphicData uri=\"http://schemas.openxmlformats.org/drawingml/2006/picture\">\
|
|
<pic:pic xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"><pic:nvPicPr><pic:cNvPr id=\"{n}\" name=\"{name}\"/><pic:cNvPicPr/></pic:nvPicPr>\
|
|
<pic:blipFill><a:blip r:embed=\"rIdImg{n}\"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill>\
|
|
<pic:spPr><a:xfrm{rot}><a:off x=\"0\" y=\"0\"/><a:ext cx=\"{cx}\" cy=\"{cy}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom></pic:spPr></pic:pic>\
|
|
</a:graphicData></a:graphic>"
|
|
)
|
|
}
|
|
|
|
fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, shape_id: &mut usize) -> String {
|
|
// One 1 pt paragraph anchors the whole page. Shapes never reserve body space, so even a
|
|
// page-sized watermark cannot push the invoice footer onto another page.
|
|
let mut s = String::from("<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr>");
|
|
let body = body_size(std::slice::from_ref(page)).max(1.0);
|
|
for b in &page.blocks {
|
|
*shape_id = shape_id.saturating_add(1);
|
|
let rotation = layout_number(-b.rotation).rem_euclid(360.0);
|
|
let (sin, cos) = rotation.to_radians().sin_cos();
|
|
let (bw, bh) = (layout_number(b.rect[2] - b.rect[0]).max(1.0), layout_number(b.rect[3] - b.rect[1]).max(1.0));
|
|
let h = if b.rotation.abs() > 0.1 { layout_number(b.size).max(1.0) } else { bh };
|
|
// Recover the unrotated width from the axis-aligned box. Use the larger axis so
|
|
// quarter turns and 45-degree watermarks do not divide by (nearly) zero.
|
|
let w = if b.rotation.abs() <= 0.1 {
|
|
bw
|
|
} else if cos.abs() >= sin.abs() {
|
|
(bw - h * sin.abs()) / cos.abs()
|
|
} else {
|
|
(bh - h * cos.abs()) / sin.abs()
|
|
};
|
|
let w = layout_number(w).max(1.0);
|
|
let (x, y) = (layout_number(b.rect[0] + (bw - w) / 2.0), layout_number(page.height - b.rect[3] + (bh - h) / 2.0));
|
|
let z = if b.size >= 2.0 * body { -2 } else { 1 };
|
|
let style = format!(
|
|
"position:absolute;margin-left:{x:.3}pt;margin-top:{y:.3}pt;width:{w:.3}pt;height:{h:.3}pt;rotation:{rotation:.3};z-index:{z};mso-position-horizontal-relative:page;mso-position-vertical-relative:page"
|
|
);
|
|
s.push_str(&format!(
|
|
"<w:r><w:pict><v:rect id=\"Text{}\" style=\"{style}\" filled=\"f\" stroked=\"f\"><v:textbox inset=\"0,0,0,0\" style=\"mso-fit-shape-to-text:t\"><w:txbxContent><w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\"/></w:pPr>{}</w:p></w:txbxContent></v:textbox><w10:wrap type=\"none\"/></v:rect></w:pict></w:r>",
|
|
shape_id, run_xml(&b.text, b.size, b.bold, b.italic, b.color)
|
|
));
|
|
}
|
|
for im in &page.images {
|
|
if !room_for(media, im) {
|
|
continue;
|
|
}
|
|
*shape_id = shape_id.saturating_add(1);
|
|
let n = media.len().saturating_add(1);
|
|
let (w, h) = (layout_number(im.size[0]).max(1.0), layout_number(im.size[1]).max(1.0));
|
|
let (x, y) = (layout_number((im.rect[0] + im.rect[2] - w) / 2.0), layout_number(page.height - (im.rect[1] + im.rect[3] + h) / 2.0));
|
|
let rotation = layout_number(-im.rotation).rem_euclid(360.0);
|
|
let name = format!("image{n}.{}", im.ext);
|
|
let pic = picture_xml(n, &name, (emu(w), emu(h)), Some(rotation));
|
|
// A DrawingML anchor, the way Word itself stores a picture placed behind text: a VML
|
|
// rectangle with an image does not show the picture.
|
|
s.push_str(&format!(
|
|
"<w:r><w:drawing><wp:anchor distT=\"0\" distB=\"0\" distL=\"0\" distR=\"0\" simplePos=\"0\" relativeHeight=\"{}\" behindDoc=\"1\" locked=\"0\" layoutInCell=\"1\" allowOverlap=\"1\">\
|
|
<wp:simplePos x=\"0\" y=\"0\"/><wp:positionH relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionH><wp:positionV relativeFrom=\"page\"><wp:posOffset>{}</wp:posOffset></wp:positionV>\
|
|
<wp:extent cx=\"{}\" cy=\"{}\"/><wp:effectExtent l=\"0\" t=\"0\" r=\"0\" b=\"0\"/><wp:wrapNone/>",
|
|
n.saturating_add(1),
|
|
emu(x),
|
|
emu(y),
|
|
emu(w),
|
|
emu(h),
|
|
));
|
|
s.push_str(&pic);
|
|
s.push_str("</wp:anchor></w:drawing></w:r>");
|
|
media.push((name, im));
|
|
}
|
|
s.push_str("</w:p>");
|
|
s
|
|
}
|
|
|
|
fn word_page_size(page: &Page) -> (i64, i64) {
|
|
let twips = |pt: f64| if pt.is_finite() { (pt * 20.0).round().clamp(144.0, 31680.0) as i64 } else { 12240 };
|
|
(twips(page.width), twips(page.height))
|
|
}
|
|
|
|
fn section(page: &Page) -> String {
|
|
let (pw, ph) = word_page_size(page);
|
|
let (mx, my) = ((pw / 8).min(1440), (ph / 8).min(1440));
|
|
format!(
|
|
"<w:sectPr><w:type w:val=\"nextPage\"/><w:pgSz w:w=\"{pw}\" w:h=\"{ph}\"/><w:pgMar w:top=\"{my}\" w:right=\"{mx}\" w:bottom=\"{my}\" w:left=\"{mx}\" w:header=\"{}\" w:footer=\"{}\" w:gutter=\"0\"/></w:sectPr>",
|
|
my / 2,
|
|
my / 2
|
|
)
|
|
}
|
|
|
|
/// The page colour of a Word document: Word has one background for the whole document, so it
|
|
/// is written only when every page has the same, non-white background (#2992).
|
|
fn word_background(pages: &[Page]) -> Option<String> {
|
|
let first = rgb8(pages.first()?.background?);
|
|
let same = pages.iter().all(|p| p.background.map(rgb8) == Some(first));
|
|
(same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2]))
|
|
}
|
|
|
|
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
|
|
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
|
|
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
|
let mut body = String::new();
|
|
let mut media: Vec<(String, &Image)> = Vec::new();
|
|
// Word needs a paragraph between adjacent tables and after the last one in the body.
|
|
let mut after_table = false;
|
|
let mut shape_id = 0usize;
|
|
for (page_index, page) in pages.iter().enumerate() {
|
|
let (pw, _) = word_page_size(page);
|
|
let text_w = (pw - 2 * (pw / 8).min(1440)) as f64 / 20.0;
|
|
if positioned(page) {
|
|
body.push_str(&positioned_page(page, &mut media, &mut shape_id));
|
|
after_table = false;
|
|
} else {
|
|
for it in items(std::slice::from_ref(page)) {
|
|
match &it {
|
|
Item::Para(runs, lvl) => {
|
|
let style = match lvl {
|
|
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
|
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
|
_ => "",
|
|
};
|
|
body.push_str("<w:p>");
|
|
body.push_str(style);
|
|
for r in runs {
|
|
body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
|
}
|
|
body.push_str("</w:p>");
|
|
}
|
|
Item::Table(t) => {
|
|
if after_table {
|
|
body.push_str("<w:p/>");
|
|
}
|
|
body.push_str(&docx_table(t));
|
|
}
|
|
Item::Img(im) => {
|
|
if room_for(&media, im) {
|
|
let n = media.len() + 1;
|
|
let name = format!("image{n}.{}", im.ext);
|
|
// Size on the page, at most the text width.
|
|
let (w, h) = ((im.rect[2] - im.rect[0]).max(1.0), (im.rect[3] - im.rect[1]).max(1.0));
|
|
let k = (text_w / w).min(1.0);
|
|
let (cx, cy) = (emu(w * k), emu(h * k));
|
|
body.push_str(&format!("<w:p><w:r><w:drawing><wp:inline><wp:extent cx=\"{cx}\" cy=\"{cy}\"/>"));
|
|
body.push_str(&picture_xml(n, &name, (cx, cy), None));
|
|
body.push_str("</wp:inline></w:drawing></w:r></w:p>");
|
|
media.push((name, im));
|
|
}
|
|
}
|
|
Item::PageBreak => body.push_str("<w:p><w:r><w:br w:type=\"page\"/></w:r></w:p>"),
|
|
}
|
|
after_table = matches!(it, Item::Table(_));
|
|
}
|
|
}
|
|
// Section breaks carry this source page's size and create exactly one next page.
|
|
// The last section must be a direct child of the body (OOXML 17.6.17).
|
|
if page_index + 1 < pages.len() {
|
|
body.push_str(&format!(
|
|
"<w:p><w:pPr><w:spacing w:before=\"0\" w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/>{}</w:pPr></w:p>",
|
|
section(page)
|
|
));
|
|
} else {
|
|
if after_table || page.blocks.is_empty() && page.images.is_empty() {
|
|
body.push_str("<w:p><w:pPr><w:spacing w:after=\"0\" w:line=\"20\" w:lineRule=\"exact\"/></w:pPr></w:p>");
|
|
}
|
|
body.push_str(§ion(page));
|
|
}
|
|
after_table = false;
|
|
}
|
|
// Word shows a document background only when its settings say so (`settings.xml` below).
|
|
let page_colour = word_background(pages);
|
|
let background = page_colour.as_deref().map(|c| format!("<w:background w:color=\"{c}\"/>")).unwrap_or_default();
|
|
let doc = format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
|
|
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \
|
|
xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:v=\"urn:schemas-microsoft-com:vml\" xmlns:w10=\"urn:schemas-microsoft-com:office:word\">{background}<w:body>{body}\
|
|
</w:body></w:document>"
|
|
);
|
|
let mut types = String::from(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<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=\"png\" ContentType=\"image/png\"/><Default Extension=\"jpg\" ContentType=\"image/jpeg\"/>\
|
|
<Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/>\
|
|
<Override PartName=\"/word/styles.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml\"/>\
|
|
<Override PartName=\"/docProps/core.xml\" ContentType=\"application/vnd.openxmlformats-package.core-properties+xml\"/>",
|
|
);
|
|
if page_colour.is_some() {
|
|
types.push_str(
|
|
"<Override PartName=\"/word/settings.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml\"/>",
|
|
);
|
|
}
|
|
types.push_str("</Types>");
|
|
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
|
|
<Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/>\
|
|
<Relationship Id=\"rId2\" Type=\"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties\" Target=\"docProps/core.xml\"/></Relationships>";
|
|
let mut doc_rels = String::from(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\
|
|
<Relationship Id=\"rIdStyles\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles\" Target=\"styles.xml\"/>",
|
|
);
|
|
for (i, (name, _)) in media.iter().enumerate() {
|
|
doc_rels.push_str(&format!(
|
|
"<Relationship Id=\"rIdImg{}\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/image\" Target=\"media/{name}\"/>",
|
|
i + 1
|
|
));
|
|
}
|
|
if page_colour.is_some() {
|
|
doc_rels.push_str("<Relationship Id=\"rIdSettings\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings\" Target=\"settings.xml\"/>");
|
|
}
|
|
doc_rels.push_str("</Relationships>");
|
|
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
|
|
<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:pPr><w:spacing w:after=\"120\"/></w:pPr></w:style>\
|
|
<w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"240\"/><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
|
|
<w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"200\"/><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
|
|
</w:styles>";
|
|
let core = format!(
|
|
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
|
|
esc(title)
|
|
);
|
|
let mut z = Zip::default();
|
|
z.add("[Content_Types].xml", types.as_bytes(), true);
|
|
z.add("_rels/.rels", rels.as_bytes(), true);
|
|
z.add("docProps/core.xml", core.as_bytes(), true);
|
|
z.add("word/document.xml", doc.as_bytes(), true);
|
|
z.add("word/styles.xml", styles.as_bytes(), true);
|
|
z.add("word/_rels/document.xml.rels", doc_rels.as_bytes(), true);
|
|
if page_colour.is_some() {
|
|
z.add("word/settings.xml", b"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:settings xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:displayBackgroundShape/></w:settings>", true);
|
|
}
|
|
for (name, im) in &media {
|
|
z.add(&format!("word/media/{name}"), &im.bytes, false);
|
|
}
|
|
z.finish()
|
|
}
|