mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* Add craft-wordmodel: WordCraft's document model without its editing code WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX, RTF, ODT and other text-format crates can read into and write from one model. Module paths, type names and serde attributes are unchanged: an app that re-exports the crate keeps its native file format. Left in the app: range editing (edit.rs), IME text and equation editing. Not model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs. * craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs The DOCX reader and writer walk an equation's runs. The walk and the child accessors it uses were in WordCraft's equation editor (math_edit.rs), which stays in the app; they are structure, not editing. * craft-wordmodel: keep the document operations (edit.rs) with the model The DOCX and RTF readers use them: alternative-format chunks are merged into a document with adopt_blocks, and a reader formats ranges with format_range and inserts objects. They operate on the document, not on a view, so they belong beside it. The tests that drove them come back with them; the paragraph walks stay in walk.rs and edit re-exports them. base64 0.23, as WordCraft and craft-offcrypto use. * craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737 The source, unchanged, so the next commit shows only what the move changes. This commit does not build on its own (it still names wordcraft-doc and wordcraft-geom, and is not a workspace member yet). * craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto - The package (zip reading, part names, relationships, content types, zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape. - Parts are parsed with craft-xml's canonical names (the Strict drawing names and mc:Choice/@Requires through its hooks), then given the tree shape the reader was written against. - Encryption and Restrict Editing hashes are craft-offcrypto's; this crate maps its errors and the w:documentProtection attributes. - wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom. Tests that covered what now lives in craft-opc and craft-offcrypto are left to those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows zip 9's fallible names. The attribute-cap test times the read only.
246 lines
10 KiB
Rust
246 lines
10 KiB
Rust
//! [`Document`] → DOCX.
|
||
|
||
mod bidi;
|
||
mod embed;
|
||
mod glossary;
|
||
mod settings_merge;
|
||
pub(crate) use settings_merge::has_unowned as settings_has_unowned;
|
||
mod math;
|
||
mod props;
|
||
mod sdt;
|
||
mod story;
|
||
pub(crate) use story::written_toc_span;
|
||
mod watermark;
|
||
mod wordart;
|
||
|
||
use std::collections::{BTreeMap, HashMap};
|
||
|
||
use craft_wordmodel::ParaProps;
|
||
use craft_wordmodel::numbering::{Level, LevelSuffix};
|
||
use craft_wordmodel::props::VAlign;
|
||
use craft_wordmodel::styles::{Style, StyleKind, TablePart};
|
||
use craft_wordmodel::{Blocks, Document, PartKind};
|
||
|
||
use crate::package::{MAX_VBA_RELATED, VBA_PROJECT_PART, VBA_RELATED, rt, zip_entries};
|
||
use crate::xml::{self, W};
|
||
use crate::{DocxError, Flavor};
|
||
|
||
mod bibliography;
|
||
mod parts;
|
||
mod settings;
|
||
mod styles;
|
||
|
||
pub(crate) use bibliography::from_source_type;
|
||
pub(crate) use parts::write_parts;
|
||
use parts::{content_type_for, push_part, sniff};
|
||
use settings::note_id_base;
|
||
pub(crate) use settings::{ANONYMOUS_AUTHOR, ANONYMOUS_INITIALS, annotation_author, durable_id_ok};
|
||
use styles::{numbering_xml, styles_xml};
|
||
|
||
/// Relationships of one part.
|
||
#[derive(Default)]
|
||
pub struct PartRels {
|
||
list: Vec<(String, String, String, bool)>,
|
||
index: HashMap<(String, String, bool), String>,
|
||
}
|
||
|
||
impl PartRels {
|
||
/// Add (or reuse) a relationship; returns its id.
|
||
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
|
||
let key = (kind.to_string(), target.to_string(), external);
|
||
if let Some(id) = self.index.get(&key) {
|
||
return id.clone();
|
||
}
|
||
let id = format!("rId{}", self.list.len() + 1);
|
||
self.list.push((id.clone(), kind.to_string(), target.to_string(), external));
|
||
self.index.insert(key, id.clone());
|
||
id
|
||
}
|
||
/// Add a relationship with a given id (the id a kept part's markup uses); a repeated id is
|
||
/// ignored. Not to be mixed with [`PartRels::add`] in one part.
|
||
pub fn add_with_id(&mut self, id: &str, kind: &str, target: &str, external: bool) {
|
||
if self.list.iter().any(|(i, ..)| i == id) {
|
||
return;
|
||
}
|
||
self.list.push((id.to_string(), kind.to_string(), target.to_string(), external));
|
||
}
|
||
pub(crate) fn is_empty(&self) -> bool {
|
||
self.list.is_empty()
|
||
}
|
||
pub(crate) fn xml(&self) -> Vec<u8> {
|
||
let mut w = W::new();
|
||
w.open("Relationships", &[("xmlns", "http://schemas.openxmlformats.org/package/2006/relationships")]);
|
||
for (id, kind, target, external) in &self.list {
|
||
if *external {
|
||
w.empty("Relationship", &[("Id", id), ("Type", kind), ("Target", target), ("TargetMode", "External")]);
|
||
} else {
|
||
w.empty("Relationship", &[("Id", id), ("Type", kind), ("Target", target)]);
|
||
}
|
||
}
|
||
w.close("Relationships");
|
||
w.into_bytes()
|
||
}
|
||
}
|
||
|
||
pub(crate) struct Writer<'d> {
|
||
doc: &'d Document,
|
||
/// Numbered display equations written so far (automatic numbers are written as text).
|
||
pub(crate) eq_number: u32,
|
||
/// Media key → file name under `word/media/`.
|
||
media_files: BTreeMap<String, String>,
|
||
bookmarks: HashMap<String, u32>,
|
||
next_rev_id: u32,
|
||
/// Move ranges open in the output: (revision, range `w:id`, destination?).
|
||
open_moves: Vec<(u32, String, bool)>,
|
||
docpr: u32,
|
||
/// Shapes' own drawing ids written so far (connectors refer to them).
|
||
shape_ids: std::collections::HashSet<u32>,
|
||
z: u32,
|
||
/// Note part id → file note id, in first-reference order.
|
||
footnotes: Vec<u32>,
|
||
endnotes: Vec<u32>,
|
||
/// Header/footer part id → (file name, is footer).
|
||
hf: Vec<(u32, String, bool)>,
|
||
/// The headers each slot has so far in body order, with sections' empty slots linked to the
|
||
/// ones before (as layout shows them): where a watermark needs a header of its own.
|
||
linked_headers: craft_wordmodel::section::HeaderSet,
|
||
/// The part id standing for a header written only to hold the watermark (no such part).
|
||
wm_header: u32,
|
||
/// The watermark shape to put at the start of the next top-level paragraph (a header's first).
|
||
pending_watermark: Option<String>,
|
||
/// Paragraphs that need a `w14:paraId` (last paragraph of each comment), by address.
|
||
para_ids: HashMap<usize, String>,
|
||
/// A note reference mark to put at the start of the next paragraph.
|
||
pending_mark: Option<&'static str>,
|
||
/// The note being written (is footnote, part id): its reference to itself is the mark above.
|
||
current_note: Option<(bool, u32)>,
|
||
/// Writing a TOC heading: its TOC field is held back so the entries become the field's result.
|
||
toc_hold_end: bool,
|
||
/// The held TOC field (instruction, locked, props), opened in the first entry as Word does.
|
||
toc_field: Option<(String, bool, bool, craft_wordmodel::props::CharProps)>,
|
||
/// Open the held TOC field at the start of the paragraph being written.
|
||
toc_begin_here: bool,
|
||
/// Close the open TOC field at the end of the paragraph being written.
|
||
toc_end_here: bool,
|
||
/// Byte offsets of the content control markers the next paragraph writes inline.
|
||
inline_controls: Vec<usize>,
|
||
/// Media keys actually referenced by a written drawing.
|
||
used_media: std::collections::BTreeSet<String>,
|
||
/// Bounds writing text boxes inside text boxes (as layout shows them).
|
||
boxes: craft_wordmodel::BoxBudget,
|
||
/// Text box stories already written: a story shown by many shapes is written once, the other
|
||
/// shapes get an empty box, so the file doesn't grow as shapes × story size.
|
||
written_boxes: std::collections::HashSet<u32>,
|
||
/// Charts, diagrams and OLE objects kept from a file: the parts they need.
|
||
embeds: embed::EmbedWriter<'d>,
|
||
/// Linked text boxes: part id → (`wps:txbx`/`wps:linkedTxbx` id, seq; 0 for the box holding
|
||
/// the text).
|
||
txbx_links: HashMap<u32, (u32, u32)>,
|
||
}
|
||
|
||
const CT_WML: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.";
|
||
|
||
/// Write a `.docx` package.
|
||
pub fn write(doc: &Document) -> Result<Vec<u8>, DocxError> {
|
||
write_as(doc, Flavor::Document)
|
||
}
|
||
|
||
/// Write a package of the given flavour (`.docx`, `.docm`, `.dotx`, `.dotm`).
|
||
pub fn write_as(doc: &Document, flavor: Flavor) -> Result<Vec<u8>, DocxError> {
|
||
zip_entries(&write_parts(doc, flavor)?)
|
||
}
|
||
|
||
impl Writer<'_> {
|
||
fn assign_media(&mut self) {
|
||
let mut used: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||
for (key, bytes) in &self.doc.media {
|
||
let clean: String = key.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')).take(80).collect();
|
||
let has_known_ext = clean.rsplit_once('.').is_some_and(|(s, _)| !s.is_empty()) && content_type_for(&clean) != "application/octet-stream";
|
||
let mut base = if has_known_ext && !clean.starts_with('.') {
|
||
clean
|
||
} else {
|
||
let stem = clean.trim_matches('.');
|
||
let stem = if stem.is_empty() { "image" } else { stem };
|
||
format!("{stem}.{}", sniff(bytes))
|
||
};
|
||
let (stem, ext) = base.rsplit_once('.').map(|(s, e)| (s.to_string(), e.to_string())).unwrap_or((base.clone(), "bin".into()));
|
||
let mut i = 1;
|
||
while used.contains(&base.to_ascii_lowercase()) {
|
||
i += 1;
|
||
base = format!("{stem}_{i}.{ext}");
|
||
}
|
||
used.insert(base.to_ascii_lowercase());
|
||
self.media_files.insert(key.clone(), base);
|
||
}
|
||
}
|
||
|
||
fn next_docpr(&mut self) -> String {
|
||
self.docpr = self.docpr.saturating_add(1);
|
||
self.docpr.to_string()
|
||
}
|
||
|
||
/// The drawing id of a shape: its own (once; a copy gets a fresh one), else a fresh one.
|
||
fn shape_docpr(&mut self, own: u32) -> String {
|
||
if own != 0 && self.shape_ids.insert(own) { own.to_string() } else { self.next_docpr() }
|
||
}
|
||
|
||
fn bookmark_id(&mut self, name: &str) -> String {
|
||
let n = self.bookmarks.len() as u32;
|
||
self.bookmarks.entry(name.to_string()).or_insert(n).to_string()
|
||
}
|
||
|
||
fn note_id(&mut self, part: u32, foot: bool) -> String {
|
||
let base = note_id_base(self.doc, foot);
|
||
let list = if foot { &mut self.footnotes } else { &mut self.endnotes };
|
||
let idx = match list.iter().position(|p| *p == part) {
|
||
Some(i) => i,
|
||
None => {
|
||
list.push(part);
|
||
list.len() - 1
|
||
}
|
||
};
|
||
(idx + base).to_string()
|
||
}
|
||
|
||
fn hf_rel(&mut self, part: u32, footer: bool, rels: &mut PartRels) -> Option<String> {
|
||
let kind = if part == self.wm_header && !footer { PartKind::Header } else { self.doc.parts.get(&part)?.kind };
|
||
if !matches!(kind, PartKind::Header | PartKind::Footer) {
|
||
return None;
|
||
}
|
||
let file = match self.hf.iter().find(|(p, _, f)| *p == part && *f == footer) {
|
||
Some((_, f, _)) => f.clone(),
|
||
None => {
|
||
let n = self.hf.iter().filter(|(_, _, f)| *f == footer).count() + 1;
|
||
let f = format!("{}{n}.xml", if footer { "footer" } else { "header" });
|
||
self.hf.push((part, f.clone(), footer));
|
||
f
|
||
}
|
||
};
|
||
Some(rels.add(if footer { rt::FOOTER } else { rt::HEADER }, &file, false))
|
||
}
|
||
|
||
fn take_para_id(&self, p: &craft_wordmodel::Paragraph) -> Option<String> {
|
||
self.para_ids.get(&(p as *const _ as usize)).cloned()
|
||
}
|
||
|
||
/// Whether `p` holds a reference to the note being written.
|
||
fn holds_own_ref(&self, p: &craft_wordmodel::Paragraph) -> bool {
|
||
let Some((foot, id)) = self.current_note else { return false };
|
||
p.objects.iter().any(|o| match o {
|
||
craft_wordmodel::InlineObject::NoteRef { kind, id: nid, .. } => {
|
||
*nid == id && (*kind == craft_wordmodel::para::NoteKind::Footnote) == foot
|
||
}
|
||
_ => false,
|
||
})
|
||
}
|
||
|
||
/// Note stories: the first paragraph starts with the note's own reference mark.
|
||
fn note_blocks(&mut self, w: &mut W, blocks: &Blocks, rels: &mut PartRels, foot: bool, part: u32) {
|
||
self.pending_mark = Some(if foot { "w:footnoteRef" } else { "w:endnoteRef" });
|
||
self.current_note = Some((foot, part));
|
||
self.blocks(w, blocks, rels, false, 0);
|
||
self.current_note = None;
|
||
self.pending_mark = None;
|
||
}
|
||
}
|