mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-03 17:28:58 +00:00
Files: documents keep their embedded alternative format chunks, and MHT opens
Word brings a document's alternative format chunks (w:altChunk) in where they stand when it opens the file; WordCraft left them out, so a document made of one (as generators and mail-merge tools write them) opened empty. - docx: read_with/ReadOptions with a ChunkImporter for formats other than WordprocessingML; nested Word documents are read here (at most 4 deep, 256 chunks and 512 MB of chunk bytes a file, XML depth counted on). - doc: Document::adopt (moved from paste) brings another document's media, lists, styles, notes, comments and revisions along under new ids; pictures in groups and charts are relinked. Insert > Text from File uses it and now keeps the file's pictures and lists. - engine: chunks in MHT, HTML (in their charset), RTF and plain text go through the format importers. - formats: MHT web archives open (MIME, quoted-printable/base64, charsets, pictures by Content-Location or cid). HTML reads style element rules (simple selectors, cascade, budgets), paragraph margins, indents, line height, cell widths and borders; an opened page takes Word's web-page defaults (Times New Roman 12 pt, single lines, black headings, automatic spacing). html::decode knows the single-byte Windows code pages. docxide-pdf suite: croatian_regulations_altchunk goes from one empty page to Word's 8 pages, SSIM 2.7% -> 78.7%; no other document changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3e2c79407d
commit
89c0d516ae
37 files changed
+2334
-229
No files matched your search
@@ -355,6 +355,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-17 | Non-overlapping body floating tables clear header tables. Original native controls distinguish never-overlap from allowed overlap. |
|
||||
| 2026-10-14 | Comment text being typed is no longer lost or misplaced (#1348): an edit or reply the document refuses (for example read-only protection) keeps the typed text in its field and says why on the status line; drafts belong to their document, so after New or Open a comment with the same number shows its own text; Save, Export, Print, Close, New and Open write pending comment text first; if the document refuses it, Save, Export and Print carry on with the stored comments and show why on the status line (a refused draft is dropped when Save is the only change), while Close, New and Open show the Save Changes prompt (Don't Save drops text the document refuses); Escape discards what was typed; in a document protected as read only or for forms the comment fields can't be typed in |
|
||||
|---|---|
|
||||
| 2026-10-22 | Documents that keep content in another format (`w:altChunk`: a web page, RTF, text or another Word document, as generators and mail-merge tools write them) show it where it stands, as Word does, instead of leaving it out; one such file in the docxide-pdf corpus opened as an empty page and now matches Word's 8 pages. `.mht` web archives open, and web pages open the way Word opens them (style sheets, paragraph margins, Times New Roman, single lines, automatic spacing). Insert › Text from File keeps the inserted file's pictures |
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
@@ -691,6 +692,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open |
|
||||
| 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes |
|
||||
| 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) |
|
||||
| 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged |
|
||||
|
||||
@@ -9,7 +9,8 @@ use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::async_saves::{AsyncSaves, SaveOutcome};
|
||||
use wordcraft_ui_egui::{Inbox, Services, WordApp};
|
||||
|
||||
const DOC_EXTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const DOC_EXTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
const IMAGE_EXTS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const RECIPIENT_EXTS: &[&str] = wordcraft_engine::cmd::mailings::RECIPIENT_EXTENSIONS;
|
||||
mod font_store;
|
||||
|
||||
@@ -389,7 +389,107 @@ fn restyle_block(b: &mut Block, from: &str, to: &Option<String>, depth: usize) -
|
||||
}
|
||||
}
|
||||
|
||||
/// One past the highest permission range id in `blocks`.
|
||||
fn perm_end<'a>(blocks: impl Iterator<Item = &'a Arc<Block>>) -> u32 {
|
||||
let mut end = 0u32;
|
||||
for b in blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
for o in &p.objects {
|
||||
if let InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } = o {
|
||||
end = end.max(id.saturating_add(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
end
|
||||
}
|
||||
|
||||
/// Text box story ids of a paragraph's shapes.
|
||||
/// What [`Document::adopt`] renumbers in another document's content: media keys, lists, note
|
||||
/// and comment stories, comment ids, text boxes (one it doesn't bring is dropped) and revisions
|
||||
/// (after ours, at `rev`).
|
||||
struct Relink<'a> {
|
||||
media: &'a BTreeMap<String, String>,
|
||||
nums: &'a BTreeMap<u32, u32>,
|
||||
notes: &'a BTreeMap<u32, u32>,
|
||||
comments: &'a BTreeMap<u32, u32>,
|
||||
boxes: &'a BTreeMap<u32, Part>,
|
||||
/// Added to permission range ids: ours come first.
|
||||
perms: u32,
|
||||
rev: u32,
|
||||
}
|
||||
|
||||
impl Relink<'_> {
|
||||
// shortcut: tracked changes of table, row and cell properties keep their revision ids; renumber them too when tables from other documents carry revisions.
|
||||
fn block(&self, b: &mut Block) {
|
||||
each_para_mut(b, 0, &mut |p| self.para(p));
|
||||
}
|
||||
|
||||
fn para(&self, p: &mut Paragraph) {
|
||||
// A tracked change's old formatting names lists as the live one does.
|
||||
let old = p.props.fmt_change.as_mut().and_then(|f| f.old.numbering.as_mut());
|
||||
for n in p.props.numbering.iter_mut().chain(old) {
|
||||
if n.num != 0 {
|
||||
n.num = self.nums.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
let shift = |r: &mut u32| *r = r.saturating_add(self.rev);
|
||||
for c in p.runs.iter_mut().map(|r| &mut r.props).chain([&mut p.mark]) {
|
||||
c.ins.iter_mut().chain(c.del.iter_mut()).for_each(shift);
|
||||
if let Some(f) = c.fmt_change.as_mut() {
|
||||
shift(&mut f.rev);
|
||||
}
|
||||
}
|
||||
if let Some(f) = p.props.fmt_change.as_mut() {
|
||||
shift(&mut f.rev);
|
||||
}
|
||||
if let Some(f) = p.section.as_deref_mut().and_then(|s| s.fmt_change.as_mut()) {
|
||||
shift(&mut f.rev);
|
||||
}
|
||||
if let Some(f) = p.props.num_change.as_mut() {
|
||||
shift(&mut f.rev);
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
self.object(o, 0);
|
||||
}
|
||||
p.touch();
|
||||
}
|
||||
|
||||
fn object(&self, o: &mut InlineObject, depth: usize) {
|
||||
// An id that names nothing here becomes one that names nothing at all.
|
||||
let map = |m: &BTreeMap<u32, u32>, id: &mut u32| *id = m.get(id).copied().unwrap_or(u32::MAX);
|
||||
match o {
|
||||
InlineObject::Image { media, .. } => {
|
||||
if let Some(k) = self.media.get(media.as_str()) {
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
InlineObject::Graphic { graphic, .. } => {
|
||||
let mapped = |m: &str| self.media.get(m).filter(|k| k.as_str() != m);
|
||||
if graphic.items.iter().any(|i| matches!(i, crate::graphic::GraphicItem::Image { media, .. } if mapped(media).is_some())) {
|
||||
for i in &mut Arc::make_mut(graphic).items {
|
||||
if let crate::graphic::GraphicItem::Image { media, .. } = i
|
||||
&& let Some(k) = self.media.get(media.as_str())
|
||||
{
|
||||
*media = k.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
InlineObject::Group { children, .. } if depth < MAX_BOX_DEPTH => children.iter_mut().for_each(|c| self.object(&mut c.obj, depth + 1)),
|
||||
InlineObject::NoteRef { id, .. } => map(self.notes, id),
|
||||
InlineObject::CommentStart { id } | InlineObject::CommentEnd { id } => map(self.comments, id),
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => *id = id.saturating_add(self.perms),
|
||||
_ => {}
|
||||
}
|
||||
for story in o.text_box_slots() {
|
||||
if story.is_some_and(|id| !self.boxes.contains_key(&id)) {
|
||||
*story = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
|
||||
p.objects.iter().flat_map(InlineObject::text_boxes)
|
||||
}
|
||||
@@ -866,6 +966,113 @@ impl Document {
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// Another document's body as a fragment of this one, to insert: the media, lists, styles,
|
||||
/// notes, comments and tracked changes it refers to come along (lists, notes, comments and
|
||||
/// revisions renumbered so they can't collide with ours; a style we already have by id stays
|
||||
/// ours), and its text boxes ride in [`Fragment::parts`].
|
||||
pub fn adopt(&mut self, src: Document) -> Fragment {
|
||||
self.adopt_in(src, &mut 0)
|
||||
}
|
||||
|
||||
/// [`Self::adopt`], its permission range ids from at least `perms` on (and past ours);
|
||||
/// `perms` moves past them.
|
||||
fn adopt_in(&mut self, src: Document, perms: &mut u32) -> Fragment {
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), self.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = self.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
abs_map.insert(a.id, id);
|
||||
self.numbering.abstracts.push(crate::numbering::AbstractNum { id, ..a.clone() });
|
||||
}
|
||||
let next_num = self.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut nums = BTreeMap::new();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
nums.insert(n.id, id);
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
self.numbering.nums.push(crate::numbering::Num { id, abstract_id, ..n.clone() });
|
||||
}
|
||||
let renumber = |n: &mut Option<crate::props::NumRef>| {
|
||||
if let Some(n) = n.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = nums.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
};
|
||||
for st in &src.styles.styles {
|
||||
if self.styles.get(&st.id).is_none() {
|
||||
let mut st = st.clone();
|
||||
// Our defaults stay ours.
|
||||
st.default = false;
|
||||
renumber(&mut st.para.numbering);
|
||||
self.styles.upsert(st);
|
||||
}
|
||||
}
|
||||
let rev = u32::try_from(self.revisions.len()).unwrap_or(u32::MAX);
|
||||
self.revisions.extend(src.revisions.iter().cloned());
|
||||
// Notes and comments get stories (and comments ids) of their own here; their text is
|
||||
// relinked below, once every new id is known.
|
||||
let mut notes = BTreeMap::new();
|
||||
for (id, part) in src.parts.iter().filter(|(_, p)| matches!(p.kind, PartKind::Footnote | PartKind::Endnote)) {
|
||||
notes.insert(*id, self.add_part(part.kind, Vec::new()));
|
||||
}
|
||||
let mut comments = BTreeMap::new();
|
||||
let mut next_comment = self.comments.keys().next_back().map_or(0, |k| k.saturating_add(1));
|
||||
for (id, c) in &src.comments {
|
||||
if src.parts.get(&c.part).is_some_and(|p| p.kind == PartKind::Comment) {
|
||||
notes.insert(c.part, self.add_part(PartKind::Comment, Vec::new()));
|
||||
comments.insert(*id, next_comment);
|
||||
next_comment = next_comment.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let boxes: BTreeMap<u32, Part> = src.parts.iter().filter(|(_, p)| p.kind == PartKind::TextBox).map(|(k, p)| (*k, p.clone())).collect();
|
||||
// Its permission ranges are numbered after ours, so no end of theirs closes one of ours.
|
||||
let first_perm = perm_end(self.body.iter().chain(self.parts.values().flat_map(|p| p.blocks.iter()))).max(*perms);
|
||||
*perms = first_perm.saturating_add(perm_end(src.body.iter().chain(src.parts.values().flat_map(|p| p.blocks.iter()))));
|
||||
let link = Relink { media: &media, nums: &nums, notes: ¬es, comments: &comments, boxes: &boxes, perms: first_perm, rev };
|
||||
let mut parts = boxes.clone();
|
||||
for part in parts.values_mut() {
|
||||
part.blocks.iter_mut().for_each(|b| link.block(Arc::make_mut(b)));
|
||||
}
|
||||
// A note's or comment's text boxes get stories here now; the body's go with the fragment.
|
||||
let mut budget = BoxBudget::new();
|
||||
for (old, new) in ¬es {
|
||||
let Some(part) = src.parts.get(old) else { continue };
|
||||
let mut blocks: Vec<Block> = part.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
if let Some(p) = self.parts.get_mut(new) {
|
||||
p.blocks = blocks.into_iter().map(Arc::new).collect();
|
||||
}
|
||||
}
|
||||
for (id, c) in &src.comments {
|
||||
if let (Some(new), Some(part)) = (comments.get(id), notes.get(&c.part)) {
|
||||
let parent = c.parent.and_then(|p| comments.get(&p).copied());
|
||||
// Its durable id is unique in its own document only: the writer gives the copy one.
|
||||
self.comments.insert(*new, crate::Comment { parent, part: *part, durable_id: None, ..c.clone() });
|
||||
}
|
||||
}
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
blocks.iter_mut().for_each(|b| link.block(b));
|
||||
Fragment { blocks, parts }
|
||||
}
|
||||
|
||||
/// [`Self::adopt`] as blocks to place in a story directly (a reader building one): its text
|
||||
/// boxes already have stories of their own here. `perms` is the first permission range id the
|
||||
/// reader hasn't given out (ranges it holds aren't in this document yet); it moves past the
|
||||
/// adopted ones.
|
||||
pub fn adopt_blocks(&mut self, src: Document, perms: &mut u32) -> Vec<Block> {
|
||||
let Fragment { mut blocks, parts } = self.adopt_in(src, perms);
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Copies of the text box stories shapes in `blocks` show, nested boxes included.
|
||||
fn text_box_parts(&self, blocks: &[Block]) -> BTreeMap<u32, Part> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
@@ -664,3 +664,167 @@ fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
|
||||
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
|
||||
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
|
||||
}
|
||||
#[test]
|
||||
fn adopting_a_document_renumbers_what_it_refers_to() {
|
||||
use crate::para::NoteKind;
|
||||
use crate::{Comment, Revision, RevisionKind};
|
||||
// Ours already has a footnote, a comment, a list and a revision with the ids the other's use.
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_note = ours.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("our note", CharProps::default()))]);
|
||||
ours.comments.insert(0, Comment { part: our_note, ..Comment::default() });
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("their note", CharProps::default()))]);
|
||||
let said = theirs.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text("their comment", CharProps::default()))]);
|
||||
theirs.comments.insert(0, Comment { author: "Them".into(), part: said, durable_id: Some("0A0B0C0D".into()), ..Comment::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Delete, ..Revision::default() });
|
||||
let mut style = crate::styles::Style { id: "TheirList".into(), name: "Their List".into(), ..Default::default() };
|
||||
style.para.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
theirs.styles.upsert(style);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(crate::props::NumRef { num: list, level: 0 });
|
||||
p.runs[0].props.del = Some(0);
|
||||
let props = p.props_of_char(0).clone();
|
||||
p.insert_object(0, InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &props).unwrap();
|
||||
p.insert_object(0, InlineObject::CommentStart { id: 0 }, &props).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let new_list = p.props.numbering.unwrap().num;
|
||||
assert_ne!(new_list, our_list);
|
||||
assert!(ours.numbering.num(new_list).is_some());
|
||||
assert_eq!(ours.styles.get("TheirList").and_then(|s| s.para.numbering).map(|n| n.num), Some(new_list));
|
||||
// Their deletion is their revision, after ours.
|
||||
let del = p.runs.iter().find_map(|r| r.props.del).unwrap();
|
||||
assert_eq!(ours.revisions.get(del as usize).map(|r| r.kind), Some(RevisionKind::Delete));
|
||||
// Their note and comment come along under ids of their own.
|
||||
let (note_id, comment_id) = p.objects.iter().fold((None, None), |(n, c), o| match o {
|
||||
InlineObject::NoteRef { id, .. } => (Some(*id), c),
|
||||
InlineObject::CommentStart { id } => (n, Some(*id)),
|
||||
_ => (n, c),
|
||||
});
|
||||
let text = |id: u32| {
|
||||
ours.parts
|
||||
.get(&id)
|
||||
.map(|p| p.blocks.iter().map(|b| if let Block::Para(p) = &**b { p.text.clone() } else { String::new() }).collect::<String>())
|
||||
};
|
||||
assert_eq!(text(note_id.unwrap()).as_deref(), Some("their note"));
|
||||
let comment = ours.comments.get(&comment_id.unwrap()).unwrap();
|
||||
assert_eq!((comment.author.as_str(), text(comment.part).as_deref()), ("Them", Some("their comment")));
|
||||
// Its durable id is the other document's: the writer gives the copy one of its own.
|
||||
assert_eq!(comment.durable_id, None);
|
||||
assert_eq!(text(our_note).as_deref(), Some("our note"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_section_breaks_tracked_change_names_its_revision() {
|
||||
use crate::{Revision, RevisionKind, SectionProps};
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.revisions.push(Revision { kind: RevisionKind::Insert, ..Revision::default() });
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.section = Some(Box::new(SectionProps { fmt_change: crate::props::PropChange::boxed(0, SectionProps::default()), ..SectionProps::default() }));
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let rev = p.section.as_ref().and_then(|s| s.fmt_change.as_ref()).map(|c| c.rev);
|
||||
assert_eq!(rev.and_then(|r| ours.revisions.get(r as usize)).map(|r| r.kind), Some(RevisionKind::Format));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_tracked_format_change_keeps_its_old_list() {
|
||||
use crate::props::{NumRef, ParaProps, PropChange};
|
||||
use crate::{Revision, RevisionKind};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let our_list = ours.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.revisions.push(Revision { kind: RevisionKind::Format, ..Revision::default() });
|
||||
let list = theirs.numbering.add_list(crate::numbering::ListKind::Bullet);
|
||||
assert_eq!(list, our_list);
|
||||
let p = theirs.para_mut(StoryRef::Body, &Path::top(0)).unwrap();
|
||||
p.props.numbering = Some(NumRef { num: list, level: 0 });
|
||||
p.props.fmt_change = PropChange::boxed(0, ParaProps { numbering: Some(NumRef { num: list, level: 0 }), ..ParaProps::default() });
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let old = &p.props.fmt_change.as_ref().unwrap().old;
|
||||
assert_eq!(old.numbering, p.props.numbering);
|
||||
assert_ne!(old.numbering.map(|n| n.num), Some(our_list));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_styles_never_become_our_defaults() {
|
||||
use crate::styles::{Style, StyleKind};
|
||||
let mut ours = doc(&["ours"]);
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
// A localized default character style, and one whose id we have too.
|
||||
theirs.styles.upsert(Style { id: "Absatz".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
theirs.styles.upsert(Style { id: "Strong".into(), kind: StyleKind::Character, default: true, ..Default::default() });
|
||||
ours.adopt(theirs);
|
||||
assert!(ours.styles.styles.iter().all(|s| !s.default));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopted_permission_ranges_keep_ids_of_their_own() {
|
||||
let perm = |id, editor: &str| InlineObject::PermStart { id, group: String::new(), editor: editor.into() };
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.insert_object(&Pos::body(0, 4), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
ours.insert_object(&Pos::body(0, 0), perm(0, "us"), &CharProps::default()).unwrap();
|
||||
// Theirs: a closed range and one left open, numbered from 0 like ours.
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
theirs.insert_object(&Pos::body(0, 6), perm(1, "them"), &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 6), InlineObject::PermEnd { id: 0 }, &CharProps::default()).unwrap();
|
||||
theirs.insert_object(&Pos::body(0, 0), perm(0, "them"), &CharProps::default()).unwrap();
|
||||
|
||||
let frag = ours.adopt(theirs);
|
||||
let Some(Block::Para(p)) = frag.blocks.first() else { panic!() };
|
||||
let ids: Vec<u32> = p
|
||||
.objects
|
||||
.iter()
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::PermStart { id, .. } | InlineObject::PermEnd { id } => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(ids.len(), 3);
|
||||
assert!(!ids.contains(&0), "{ids:?}");
|
||||
assert_eq!(ids[0], ids[1], "a range's start and end still pair: {ids:?}");
|
||||
assert_ne!(ids[1], ids[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_adopted_notes_text_box_gets_a_story_of_its_own() {
|
||||
use crate::para::NoteKind;
|
||||
// Ours has a text box under the id their note's text box has.
|
||||
let mut ours = doc(&["ours"]);
|
||||
ours.add_part(PartKind::Footnote, Vec::new());
|
||||
let our_box = text_box(&mut ours, &Pos::body(0, 0), "our box");
|
||||
let mut theirs = doc(&["theirs"]);
|
||||
let note = theirs.add_part(PartKind::Footnote, vec![para_block(Paragraph::with_text("note", CharProps::default()))]);
|
||||
let their_box = theirs.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("their box", CharProps::default()))]);
|
||||
assert_eq!(their_box, our_box);
|
||||
theirs
|
||||
.insert_object(&Pos { story: StoryRef::Part(note), path: Path::top(0), off: 0 }, box_shape(Some(their_box)), &CharProps::default())
|
||||
.unwrap();
|
||||
theirs
|
||||
.insert_object(&Pos::body(0, 0), InlineObject::NoteRef { kind: NoteKind::Footnote, id: note, custom: String::new() }, &CharProps::default())
|
||||
.unwrap();
|
||||
|
||||
let blocks = ours.adopt(theirs).blocks;
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let new_note = p.objects.iter().find_map(|o| if let InlineObject::NoteRef { id, .. } = o { Some(*id) } else { None }).unwrap();
|
||||
let story = ours.parts[&new_note]
|
||||
.blocks
|
||||
.iter()
|
||||
.find_map(|b| if let Block::Para(p) = &**b { p.objects.iter().find_map(|o| o.text_boxes().first().copied()) } else { None })
|
||||
.unwrap();
|
||||
assert_ne!(story, our_box);
|
||||
let part = &ours.parts[&story];
|
||||
assert_eq!(part.kind, PartKind::TextBox);
|
||||
assert!(matches!(part.blocks.first().map(|b| &**b), Some(Block::Para(p)) if p.text == "their box"));
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub fn sniff_xml(bytes: &[u8]) -> XmlKind {
|
||||
|
||||
/// Read a Flat OPC file.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&unpack(bytes)?)
|
||||
crate::read::read_opened(&unpack(bytes)?, None)
|
||||
}
|
||||
|
||||
/// The data element of the part being read.
|
||||
|
||||
+11
-4
@@ -32,7 +32,7 @@ pub use crypt::{
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
pub use read::read;
|
||||
pub use read::{AltChunk, ChunkImporter, ReadOptions, read};
|
||||
pub use smart_art::smart_art_items;
|
||||
pub use wordml2003_write::write as write_wordml2003;
|
||||
pub use write::{write, write_as};
|
||||
@@ -84,11 +84,18 @@ pub(crate) const CANVAS_SPIN_EXT: &str = "urn:wordcraft:canvas-xfrm";
|
||||
/// `password` first. An encrypted package without a password gives
|
||||
/// [`DocxError::PasswordRequired`]; a wrong one, [`DocxError::WrongPassword`].
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
read_with(bytes, &ReadOptions { password, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Read a package with options: the password of an encrypted one ([`read_with_password`]) and
|
||||
/// an importer for `w:altChunk` content in formats other than WordprocessingML (see
|
||||
/// [`ChunkImporter`]).
|
||||
pub fn read_with(bytes: &[u8], opts: &ReadOptions) -> Result<wordcraft_doc::Document, DocxError> {
|
||||
if is_encrypted(bytes) {
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, password)?);
|
||||
return read::read_package(&package);
|
||||
let package = zeroize::Zeroizing::new(decrypt(bytes, opts.password)?);
|
||||
return read::read_package(&package, opts.chunks);
|
||||
}
|
||||
read(bytes)
|
||||
read::read_package(bytes, opts.chunks)
|
||||
}
|
||||
|
||||
/// Which kind of WordprocessingML package to write. The main part's content type differs per
|
||||
|
||||
@@ -47,6 +47,7 @@ pub mod rt {
|
||||
pub const DIAGRAM_COLORS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramColors";
|
||||
pub const CUSTOM_XML: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
|
||||
pub const GLOSSARY: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument";
|
||||
pub const AF_CHUNK: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk";
|
||||
}
|
||||
|
||||
/// Content type of a glossary document part (ECMA-376 Part 1 §11.3.8).
|
||||
@@ -252,6 +253,11 @@ impl Package {
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// The bytes of all its parts.
|
||||
pub fn size(&self) -> u64 {
|
||||
self.files.values().map(|f| f.len() as u64).sum()
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
@@ -587,7 +593,7 @@ mod tests {
|
||||
("word/media/pic.png", b"\x89PNG picture bytes".to_vec()),
|
||||
]);
|
||||
let pkg = Package::open(&z).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg).unwrap();
|
||||
let doc = crate::read::read_opened(&pkg, None).unwrap();
|
||||
let held = doc.media.values().next().expect("the picture is read");
|
||||
assert!(Arc::ptr_eq(held, pkg.get_shared("word/media/pic.png").unwrap()), "the same bytes, not a copy");
|
||||
assert_eq!(pkg.get("/WORD/media/PIC.png"), Some(&b"\x89PNG picture bytes"[..]), "lookups are unchanged");
|
||||
|
||||
+26
-11
@@ -1,6 +1,7 @@
|
||||
//! DOCX → [`Document`].
|
||||
|
||||
pub(crate) mod chart;
|
||||
mod chunk;
|
||||
pub(crate) mod diagram;
|
||||
mod drawing_color;
|
||||
mod embed;
|
||||
@@ -13,7 +14,7 @@ mod shape_style;
|
||||
mod story;
|
||||
mod wordart;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::control::{ControlEvent, ControlKind, control_events, pair_events};
|
||||
@@ -46,6 +47,9 @@ pub(crate) use meta::PROTECTION_HASH_ATTRS;
|
||||
pub(crate) use styles::LOWER_NAMES;
|
||||
use toc::{is_toc, toc_control_to_toc, toc_field_to_heading, unstyle_toc_links};
|
||||
|
||||
use chunk::ChunkState;
|
||||
pub use chunk::{AltChunk, ChunkImporter, ReadOptions};
|
||||
|
||||
/// Most fonts read from a font table, and the longest name (hostile files).
|
||||
const MAX_TABLE_FONTS: usize = 2000;
|
||||
const MAX_FONT_NAME: usize = 256;
|
||||
@@ -87,12 +91,12 @@ pub(crate) struct Reader<'p> {
|
||||
pub bookmarks: HashMap<String, String>,
|
||||
/// Restrict Editing exception ranges (`w:permStart`): the file's id → ours.
|
||||
pub perms: HashMap<String, u32>,
|
||||
/// The next permission range id to give out (chunks' ranges take theirs from it too).
|
||||
pub next_perm: u32,
|
||||
/// Chart and diagram parts by path, parsed once.
|
||||
graphic_parts: HashMap<String, Option<Arc<El>>>,
|
||||
/// Charts and diagrams by (kind, part path, width bits, height bits), built once.
|
||||
graphics: HashMap<(GraphicKind, String, u32, u32), Arc<Graphic>>,
|
||||
/// Graphic work left before charts and diagrams are left empty (see [`MAX_GRAPHIC_WORK`]).
|
||||
graphic_budget: usize,
|
||||
/// Drawing ids connectors are glued to (`a:stCxn`, `a:endCxn`), and whether any shape's id
|
||||
/// was kept while reading (those not glued to are dropped at the end).
|
||||
pub glued: HashSet<u32>,
|
||||
@@ -103,6 +107,8 @@ pub(crate) struct Reader<'p> {
|
||||
content_types: Option<ContentTypes>,
|
||||
/// Parts kept for charts, diagrams and OLE objects (see `embed`).
|
||||
embedded: EmbeddedManifest,
|
||||
/// What alternative format chunks may still bring in, and how (see [`chunk`]).
|
||||
chunks: ChunkState<'p>,
|
||||
/// Linked text boxes as read: (`wps:txbx/@id` or `wps:linkedTxbx/@id`, `seq` (0 for the box
|
||||
/// holding the text), text box part). See [`link_text_boxes`].
|
||||
pub txbx_links: Vec<(u32, u32, u32)>,
|
||||
@@ -125,17 +131,24 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
if crate::is_encrypted(bytes) {
|
||||
return crate::read_with_password(bytes, None);
|
||||
}
|
||||
read_package(bytes)
|
||||
read_package(bytes, None)
|
||||
}
|
||||
|
||||
/// [`read`] for bytes known not to be an encrypted package.
|
||||
pub(crate) fn read_package(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?)
|
||||
/// [`read`] for bytes known not to be an encrypted package; `chunks` imports alternative format
|
||||
/// chunks in formats other than WordprocessingML.
|
||||
pub(crate) fn read_package(bytes: &[u8], chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened(&Package::open(bytes)?, chunks)
|
||||
}
|
||||
|
||||
/// Read a package whose parts are already unpacked (from a zip, a Flat OPC file or a converted
|
||||
/// Word 2003 XML document).
|
||||
pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
pub(crate) fn read_opened(pkg: &Package, chunks: Option<ChunkImporter>) -> Result<Document, DocxError> {
|
||||
read_opened_in(pkg, ChunkState::new(chunks)).map(|(doc, ..)| doc)
|
||||
}
|
||||
|
||||
/// [`read_opened`] with what chunks may still bring in; also gives the chunks, chunk bytes and
|
||||
/// graphic work left of that.
|
||||
fn read_opened_in(pkg: &Package, chunks: ChunkState) -> Result<(Document, usize, u64, usize), DocxError> {
|
||||
let root_rels = pkg.rels("");
|
||||
let main = root_rels.by_type(rt::OFFICE_DOC).filter(|r| !r.external).map(|r| r.target.clone()).unwrap_or_else(|| "word/document.xml".to_string());
|
||||
let Some(root) = pkg.xml_strict(&main)? else {
|
||||
@@ -164,9 +177,9 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
comments_ended: HashSet::new(),
|
||||
bookmarks: HashMap::new(),
|
||||
perms: HashMap::new(),
|
||||
next_perm: 0,
|
||||
graphic_parts: HashMap::new(),
|
||||
graphics: HashMap::new(),
|
||||
graphic_budget: MAX_GRAPHIC_WORK,
|
||||
glued: HashSet::new(),
|
||||
shape_ids: false,
|
||||
main: main.clone(),
|
||||
@@ -175,6 +188,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
txbx_links: Vec::new(),
|
||||
theme_fills: Default::default(),
|
||||
group_fill: None,
|
||||
chunks,
|
||||
};
|
||||
r.pc.major_font = r.doc.settings.major_font.clone();
|
||||
r.pc.minor_font = r.doc.settings.minor_font.clone();
|
||||
@@ -267,7 +281,7 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if let Some(body) = root.child("w:body") {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, 0);
|
||||
r.read_blocks(&mut sc, body, &rels, &mut blocks, r.chunks.depth);
|
||||
r.flush_pending(&mut sc, &mut blocks);
|
||||
r.doc.body = blocks;
|
||||
unstyle_toc_links(&mut r.doc);
|
||||
@@ -321,9 +335,10 @@ pub(crate) fn read_opened(pkg: &Package) -> Result<Document, DocxError> {
|
||||
if r.shape_ids {
|
||||
keep_glued_ids(&mut r.doc, &r.glued);
|
||||
}
|
||||
let (left, bytes, graphic) = (r.chunks.left, r.chunks.bytes, r.chunks.graphic);
|
||||
let mut doc = r.doc;
|
||||
doc.ensure_nonempty();
|
||||
Ok(doc)
|
||||
Ok((doc, left, bytes, graphic))
|
||||
}
|
||||
|
||||
/// Most move ranges open at once, and most move names tracked, per file.
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
//! `w:altChunk` (ECMA-376 §17.17.2.1): content kept in another format (HTML, MHT, RTF, plain
|
||||
//! text, another WordprocessingML document) that Word brings into the document where the chunk
|
||||
//! stands when it opens the file. A nested WordprocessingML package is read here; other formats
|
||||
//! go to the caller's [`ChunkImporter`] (the format crates sit beside this one), and without one
|
||||
//! the chunk is left out.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Block, Blocks, Document};
|
||||
|
||||
use super::story::StoryCtx;
|
||||
use super::{Reader, part_of};
|
||||
use crate::package::{ContentTypes, MAX_PART, MAX_TOTAL, Package, Rels, rt};
|
||||
use crate::xml::El;
|
||||
|
||||
/// Most chunks brought in from one file (nested documents' chunks included).
|
||||
pub(crate) const MAX_CHUNKS: usize = 256;
|
||||
/// Deepest a WordprocessingML chunk may nest in another.
|
||||
pub(crate) const MAX_CHUNK_NESTING: u8 = 4;
|
||||
|
||||
/// An alternative format chunk's content, as a [`ChunkImporter`] gets it.
|
||||
pub struct AltChunk<'a> {
|
||||
/// The part's content type (`application/xhtml+xml`, `message/rfc822`, `application/rtf`,
|
||||
/// `text/plain`…), empty when the package doesn't give one.
|
||||
pub content_type: &'a str,
|
||||
/// The part's name (`word/afchunk.mht`): its extension tells the format when the content type
|
||||
/// doesn't.
|
||||
pub part: &'a str,
|
||||
pub bytes: &'a [u8],
|
||||
}
|
||||
|
||||
/// Turns a chunk the docx reader can't read itself into a document whose body takes the chunk's
|
||||
/// place; `None` leaves the chunk out.
|
||||
pub type ChunkImporter<'a> = &'a dyn Fn(&AltChunk) -> Option<Document>;
|
||||
|
||||
/// How [`crate::read_with`] reads a package.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub struct ReadOptions<'a> {
|
||||
/// The password of an encrypted package.
|
||||
pub password: Option<&'a str>,
|
||||
/// Imports `w:altChunk` content in formats other than WordprocessingML.
|
||||
pub chunks: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// Bring the chunk `e` names into `out` where it stands (`depth`: of the XML around it).
|
||||
pub(crate) fn read_alt_chunk(&mut self, sc: &mut StoryCtx, e: &El, rels: &Rels, out: &mut Blocks, depth: usize) {
|
||||
let Some(part) = e.attr("r:id").and_then(|id| part_of(rels, id, rt::AF_CHUNK)) else { return };
|
||||
let pkg = self.pkg;
|
||||
let Some(bytes) = pkg.get(&part) else {
|
||||
log::warn!("docx: alternative format chunk {part} is missing");
|
||||
return;
|
||||
};
|
||||
// Every chunk's bytes count against one budget for the whole file, however often the same
|
||||
// part is named and however deep chunks nest.
|
||||
if self.chunks.left == 0 || bytes.len() as u64 > self.chunks.bytes {
|
||||
log::warn!("docx: more than {MAX_CHUNKS} alternative format chunks or {MAX_TOTAL} bytes of them; the rest are left out");
|
||||
return;
|
||||
}
|
||||
self.chunks.left -= 1;
|
||||
self.chunks.bytes -= bytes.len() as u64;
|
||||
let ct = self.content_types.get_or_insert_with(|| ContentTypes::read(pkg)).of(&part).unwrap_or("").to_string();
|
||||
let chunk = AltChunk { content_type: &ct, part: &part, bytes };
|
||||
// A WordprocessingML chunk is a package; one that isn't Word's fails to read (logged).
|
||||
let doc = if bytes.starts_with(b"PK\x03\x04") { self.read_nested(bytes, depth) } else { self.chunks.import.and_then(|f| f(&chunk)) };
|
||||
let Some(mut doc) = doc else {
|
||||
log::warn!("docx: alternative format chunk {part} ({ct}) is in a format WordCraft can't bring in");
|
||||
return;
|
||||
};
|
||||
// The chunk flows in the section it stands in; its own section breaks don't come along.
|
||||
for b in &mut doc.body {
|
||||
if let Block::Para(p) = &**b
|
||||
&& p.section.is_some()
|
||||
&& let Block::Para(p) = Arc::make_mut(b)
|
||||
{
|
||||
p.section = None;
|
||||
}
|
||||
}
|
||||
let mut blocks = self.doc.adopt_blocks(doc, &mut self.next_perm);
|
||||
// Bookmarks and comment marks read before the chunk stay before it.
|
||||
self.flush_pending_before(sc, out, &mut blocks);
|
||||
out.extend(blocks.into_iter().map(Arc::new));
|
||||
}
|
||||
|
||||
/// A WordprocessingML chunk: its document, read with the same importer and what is left of
|
||||
/// the budgets, its XML depth counting on from `depth`.
|
||||
fn read_nested(&mut self, bytes: &[u8], depth: usize) -> Option<Document> {
|
||||
if self.chunks.nesting >= MAX_CHUNK_NESTING {
|
||||
log::warn!("docx: alternative format chunks nest deeper than {MAX_CHUNK_NESTING}; the inner ones are left out");
|
||||
return None;
|
||||
}
|
||||
let pkg =
|
||||
Package::open_limited(bytes, MAX_PART, self.chunks.bytes).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.bytes = self.chunks.bytes.saturating_sub(pkg.size());
|
||||
let state = ChunkState { nesting: self.chunks.nesting + 1, depth: depth + 1, ..self.chunks };
|
||||
let (doc, left, bytes, graphic) =
|
||||
super::read_opened_in(&pkg, state).map_err(|e| log::warn!("docx: unreadable nested document chunk: {e}")).ok()?;
|
||||
self.chunks.left = left;
|
||||
self.chunks.bytes = bytes;
|
||||
self.chunks.graphic = graphic;
|
||||
Some(doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reader may still bring in from chunks.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ChunkState<'a> {
|
||||
/// Chunks left in the file's budget ([`MAX_CHUNKS`]).
|
||||
pub left: usize,
|
||||
/// Chunk bytes (nested packages unpacked) left in the file's budget.
|
||||
pub bytes: u64,
|
||||
/// Graphic work left in the file's budget ([`super::MAX_GRAPHIC_WORK`]): nested documents'
|
||||
/// charts and diagrams count against it too.
|
||||
pub graphic: usize,
|
||||
/// How deep in nested WordprocessingML chunks this reader is.
|
||||
pub nesting: u8,
|
||||
/// The XML depth its stories start at (a nested document's: that of the chunk naming it).
|
||||
pub depth: usize,
|
||||
pub import: Option<ChunkImporter<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> ChunkState<'a> {
|
||||
pub fn new(import: Option<ChunkImporter<'a>>) -> Self {
|
||||
ChunkState { left: MAX_CHUNKS, bytes: MAX_TOTAL, graphic: super::MAX_GRAPHIC_WORK, nesting: 0, depth: 0, import }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{Block, Document, InlineObject};
|
||||
|
||||
use super::ChunkState;
|
||||
use crate::package::Package;
|
||||
|
||||
/// A package whose body is `body`, with the document relationship `rel` (id, type, target) to
|
||||
/// the part `(name, bytes)`.
|
||||
fn package(body: &str, (id, ty, target): (&str, &str, &str), (name, bytes): (&str, &[u8])) -> Vec<u8> {
|
||||
let ns = r#"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:a="http://schemas.openxmlformats.org/drawingml/2006/main""#;
|
||||
let rels =
|
||||
|inner: &str| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
|
||||
let rel = |id: &str, ty: &str, target: &str| {
|
||||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
|
||||
};
|
||||
let files: [(&str, Vec<u8>); 4] = [
|
||||
("_rels/.rels", rels(&rel("rId1", "officeDocument", "word/document.xml")).into_bytes()),
|
||||
("word/document.xml", format!(r#"<w:document {ns}><w:body>{body}</w:body></w:document>"#).into_bytes()),
|
||||
("word/_rels/document.xml.rels", rels(&rel(id, ty, target)).into_bytes()),
|
||||
(name, bytes.to_vec()),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// Whether the body's first paragraph's chart is drawn.
|
||||
fn drawn(d: &Document) -> bool {
|
||||
let Some(Block::Para(p)) = d.body.first().map(|b| &**b) else { panic!("no paragraph") };
|
||||
p.objects.iter().any(|o| matches!(o, InlineObject::Graphic { graphic, .. } if !graphic.items.is_empty()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_documents_charts_count_against_the_files_graphic_budget() {
|
||||
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
|
||||
let drawing = r#"<w:p><w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rIdChart"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
|
||||
let inner = package(drawing, ("rIdChart", "chart", "charts/chart1.xml"), ("word/charts/chart1.xml", chart.as_bytes()));
|
||||
let outer = package(r#"<w:altChunk r:id="rIdC"/>"#, ("rIdC", "aFChunk", "afchunk.docx"), ("word/afchunk.docx", &inner));
|
||||
let pkg = Package::open(&outer).unwrap();
|
||||
let read = |graphic| {
|
||||
let (d, .., left) = super::super::read_opened_in(&pkg, ChunkState { graphic, ..ChunkState::new(None) }).unwrap();
|
||||
(drawn(&d), left)
|
||||
};
|
||||
let (shown, left) = read(super::super::MAX_GRAPHIC_WORK);
|
||||
assert!(shown);
|
||||
assert!(left < super::super::MAX_GRAPHIC_WORK, "the nested chart's work is the file's");
|
||||
// With the file's budget spent, the nested document doesn't get one of its own.
|
||||
assert_eq!(read(0), (false, 0));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, BTreeMap, Blocks, Comment, DocxError, El, HashMap, HashSet, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph,
|
||||
PartKind, Reader, Rels, Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
Arc, Blocks, Comment, DocxError, El, HashMap, MAX_PARTS, MAX_SOURCES, MAX_TEMPLATE_PATH, NoteKind, NumFormat, Paragraph, PartKind, Reader, Rels,
|
||||
Seen, StoryCtx, Zoom, ZoomFit, int, math, on_off, para_block, props, tw, u32_of,
|
||||
};
|
||||
|
||||
impl Reader<'_> {
|
||||
@@ -120,7 +120,7 @@ impl Reader<'_> {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.current_note = Some((kind, id));
|
||||
self.read_blocks(&mut sc, n, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, n, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.current_note = None;
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
@@ -152,7 +152,7 @@ impl Reader<'_> {
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.in_note_sep = true;
|
||||
self.read_blocks(&mut sc, n, rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, n, rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
self.in_note_sep = false;
|
||||
if blocks.is_empty() {
|
||||
@@ -188,11 +188,7 @@ impl Reader<'_> {
|
||||
let rels = self.pkg.rels(path);
|
||||
let mut by_para: HashMap<String, u32> = HashMap::new();
|
||||
let mut next_id = 0u32;
|
||||
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
|
||||
// Ids given before this part, looked up rather than scanned for every generated id: a
|
||||
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
|
||||
// `entries`.
|
||||
let given: HashSet<u32> = self.comment_map.values().copied().collect();
|
||||
// Straight into the document, beside the comments chunks in notes brought in.
|
||||
for c in root.children("w:comment") {
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
@@ -202,23 +198,26 @@ impl Reader<'_> {
|
||||
continue;
|
||||
}
|
||||
let id = match file_id.parse::<u32>() {
|
||||
Ok(v) if !entries.contains_key(&v) => v,
|
||||
Ok(v) if !self.doc.comments.contains_key(&v) => v,
|
||||
_ => {
|
||||
while entries.contains_key(&next_id) || given.contains(&next_id) {
|
||||
// Every id handed out so far is a key of `doc.comments` (inserted below).
|
||||
while self.doc.comments.contains_key(&next_id) {
|
||||
next_id += 1;
|
||||
}
|
||||
next_id
|
||||
}
|
||||
};
|
||||
// Taken before its text is read: a chunk there brings comments of its own.
|
||||
self.doc.comments.insert(id, Comment::default());
|
||||
let mut sc = StoryCtx::default();
|
||||
let mut blocks = Blocks::new();
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, c, &rels, &mut blocks, self.chunks.depth);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
let part = self.doc.add_part(PartKind::Comment, blocks);
|
||||
if let Some(pid) = c.children("w:p").last().and_then(|p| p.attr("w14:paraId")) {
|
||||
by_para.insert(pid.to_string(), id);
|
||||
}
|
||||
entries.insert(
|
||||
self.doc.comments.insert(
|
||||
id,
|
||||
Comment {
|
||||
author: c.attr("w:author").unwrap_or("").to_string(),
|
||||
@@ -239,7 +238,7 @@ impl Reader<'_> {
|
||||
let Some(cid) = e.attr("w15:paraId").and_then(|p| by_para.get(p)).copied() else { continue };
|
||||
let parent = e.attr("w15:paraIdParent").and_then(|p| by_para.get(p)).copied().filter(|p| *p != cid);
|
||||
let done = e.attr("w15:done").is_some_and(|v| v == "1" || v == "true");
|
||||
if let Some(c) = entries.get_mut(&cid) {
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.parent = parent;
|
||||
c.resolved = done;
|
||||
}
|
||||
@@ -258,7 +257,7 @@ impl Reader<'_> {
|
||||
for e in x.els().filter(|e| e.local() == "commentId") {
|
||||
let Some(cid) = local(e, "paraId").and_then(|p| by_para.get(&p).copied()) else { continue };
|
||||
let Some(durable) = local(e, "durableId").filter(|d| crate::write::durable_id_ok(d)) else { continue };
|
||||
if let Some(c) = entries.get_mut(&cid) {
|
||||
if let Some(c) = self.doc.comments.get_mut(&cid) {
|
||||
c.durable_id = Some(durable.to_ascii_uppercase());
|
||||
by_durable.insert(durable.to_ascii_uppercase(), cid);
|
||||
}
|
||||
@@ -270,12 +269,11 @@ impl Reader<'_> {
|
||||
for e in x.els().filter(|e| e.local() == "commentExtensible") {
|
||||
let Some(cid) = local(e, "durableId").and_then(|d| by_durable.get(&d.to_ascii_uppercase()).copied()) else { continue };
|
||||
let date = local(e, "dateUtc").filter(|d| d.len() <= 40 && d.chars().all(|c| c.is_ascii_alphanumeric() || ":-.+".contains(c)));
|
||||
if let (Some(c), Some(date)) = (entries.get_mut(&cid), date) {
|
||||
if let (Some(c), Some(date)) = (self.doc.comments.get_mut(&cid), date) {
|
||||
c.date_utc = Some(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.doc.comments = entries;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ impl Reader<'_> {
|
||||
sc.in_hf = true;
|
||||
let mut blocks = Blocks::new();
|
||||
self.hf_depth += 1;
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut sc, &root, &rels, &mut blocks, self.chunks.depth);
|
||||
self.hf_depth = self.hf_depth.saturating_sub(1);
|
||||
self.flush_pending(&mut sc, &mut blocks);
|
||||
if let Some(p) = self.doc.parts.get_mut(&id)
|
||||
|
||||
@@ -51,6 +51,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:sdt" => self.read_block_sdt(sc, e, rels, out, depth),
|
||||
"w:altChunk" => self.read_alt_chunk(sc, e, rels, out, depth),
|
||||
"w:customXml" | "w:smartTag" | "w:ins" | "w:moveTo" => self.read_blocks(sc, e, rels, out, depth + 1),
|
||||
// Math is allowed straight in a story (EG_BlockLevelElts takes EG_RunLevelElements,
|
||||
// which takes EG_MathContent): it gets a paragraph of its own (#1273).
|
||||
@@ -135,6 +136,21 @@ impl Reader<'_> {
|
||||
sc.pending.clear();
|
||||
}
|
||||
|
||||
/// Markers read before content placed whole (a chunk's `next`) stay before it: at the end of
|
||||
/// the paragraph before, or after a table or with none, at the start of the content's first
|
||||
/// paragraph, as a paragraph read there would take them (with a table first, the next
|
||||
/// paragraph read takes them).
|
||||
pub fn flush_pending_before(&mut self, sc: &mut StoryCtx, out: &mut Blocks, next: &mut [Block]) {
|
||||
if out.last().is_some_and(|b| matches!(**b, Block::Para(_))) {
|
||||
return self.flush_pending(sc, out);
|
||||
}
|
||||
if let Some(Block::Para(p)) = next.first_mut() {
|
||||
for o in sc.pending.drain(..).rev() {
|
||||
let _ = p.insert_object(0, o, &CharProps::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn marker(&mut self, e: &El) -> Option<InlineObject> {
|
||||
let id = e.attr("w:id").map(str::trim).unwrap_or("").to_string();
|
||||
match e.name.as_str() {
|
||||
@@ -161,8 +177,15 @@ impl Reader<'_> {
|
||||
if self.perms.len() >= 100_000 {
|
||||
return None;
|
||||
}
|
||||
let n = self.perms.len() as u32;
|
||||
let id = *self.perms.entry(id).or_insert(n);
|
||||
let id = match self.perms.get(&id) {
|
||||
Some(n) => *n,
|
||||
None => {
|
||||
let n = self.next_perm;
|
||||
self.next_perm = n.saturating_add(1);
|
||||
self.perms.insert(id, n);
|
||||
n
|
||||
}
|
||||
};
|
||||
let text = |a: &str| e.attr(a).filter(|v| v.len() <= 256).unwrap_or("").to_string();
|
||||
let (group, editor) = (text("w:edGrp"), text("w:ed"));
|
||||
if group.is_empty() && editor.is_empty() {
|
||||
|
||||
@@ -218,8 +218,8 @@ impl Reader<'_> {
|
||||
if let Some(g) = self.graphics.get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let items = if self.graphic_budget == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
|
||||
self.graphic_budget = self.graphic_budget.saturating_sub(graphic_work(&items));
|
||||
let items = if self.chunks.graphic == 0 { Vec::new() } else { self.graphic_items(kind, &key.1, w, h) };
|
||||
self.chunks.graphic = self.chunks.graphic.saturating_sub(graphic_work(&items));
|
||||
// A chart or SmartArt that is exactly what WordCraft writes for its model can be edited
|
||||
// (and is written from the model); any other stays as it was read.
|
||||
let chart = match kind {
|
||||
@@ -556,7 +556,7 @@ impl Reader<'_> {
|
||||
let mut blocks = Blocks::new();
|
||||
// Shapes in the text aren't members of a group the box may be in.
|
||||
let group = self.group_fill.take();
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, 0);
|
||||
self.read_blocks(&mut inner, content, rels, &mut blocks, self.chunks.depth);
|
||||
self.group_fill = group;
|
||||
self.flush_pending(&mut inner, &mut blocks);
|
||||
self.doc.add_part(PartKind::TextBox, blocks)
|
||||
|
||||
@@ -33,7 +33,7 @@ const MAX_STORIES: usize = 50_000;
|
||||
|
||||
/// Read a Word 2003 XML Document.
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocxError> {
|
||||
crate::read::read_opened(&convert(bytes)?)
|
||||
crate::read::read_opened(&convert(bytes)?, None)
|
||||
}
|
||||
|
||||
/// Parts the converted package gets relationships for.
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Alternative format chunks (`w:altChunk`, ECMA-376 §17.17.2.1): content in another format that
|
||||
//! is brought in where the chunk stands. All markup here is synthetic.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use wordcraft_doc::{Block, Document, InlineObject, StoryRef};
|
||||
use wordcraft_docx::{AltChunk, ReadOptions, read, read_with};
|
||||
|
||||
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
|
||||
const ROOT_RELS: &str = r#"<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"/></Relationships>"#;
|
||||
const DOCX_MAIN: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml";
|
||||
|
||||
fn p(text: &str) -> String {
|
||||
format!(r#"<w:p><w:r><w:t xml:space="preserve">{text}</w:t></w:r></w:p>"#)
|
||||
}
|
||||
|
||||
/// A package whose body is `body`, with the chunk `afchunk.{ext}` (content type `ct`) as `rIdC`.
|
||||
fn package(body: &str, chunk: Option<(&str, &str, &[u8])>) -> Vec<u8> {
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
|
||||
let mut files: Vec<(String, Vec<u8>)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())];
|
||||
let mut types = String::from(
|
||||
r#"<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"/>"#,
|
||||
);
|
||||
if let Some((ext, ct, bytes)) = chunk {
|
||||
types.push_str(&format!(r#"<Default Extension="{ext}" ContentType="{ct}"/>"#));
|
||||
files.push((
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdC" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="/word/afchunk.{ext}"/></Relationships>"#).into_bytes(),
|
||||
));
|
||||
files.push((format!("word/afchunk.{ext}"), bytes.to_vec()));
|
||||
}
|
||||
types.push_str(r#"<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#);
|
||||
files.push(("[Content_Types].xml".into(), types.into_bytes()));
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
const CHUNK: &str = r#"<w:altChunk r:id="rIdC"/>"#;
|
||||
|
||||
fn body_lines(d: &Document) -> Vec<String> {
|
||||
d.plain_text(StoryRef::Body).split('\n').map(str::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_comes_in_through_the_importer_where_it_stands() {
|
||||
let html = b"<p>Chunk one</p><p>Chunk two</p>";
|
||||
let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("htm", "application/xhtml+xml", html)));
|
||||
let seen = std::cell::RefCell::new(Vec::new());
|
||||
let import = |c: &AltChunk| {
|
||||
seen.borrow_mut().push((c.content_type.to_string(), c.part.to_string(), c.bytes.to_vec()));
|
||||
Some(Document::from_text("Chunk one\nChunk two"))
|
||||
};
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
assert_eq!(body_lines(&d), ["Before", "Chunk one", "Chunk two", "After"]);
|
||||
assert_eq!(*seen.borrow(), [("application/xhtml+xml".to_string(), "word/afchunk.htm".to_string(), html.to_vec())]);
|
||||
// Without an importer the chunk is left out.
|
||||
assert_eq!(body_lines(&read(&bytes).unwrap()), ["Before", "After"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bookmark_opened_before_a_chunk_at_the_start_of_the_body_is_kept() {
|
||||
let body = format!(r#"<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
|
||||
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
|
||||
let import = |_: &AltChunk| Some(Document::from_text("Chunk"));
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
assert_eq!(body_lines(&d), ["Chunk", "After"]);
|
||||
let marks = d.bookmarks();
|
||||
assert_eq!(marks.len(), 1);
|
||||
assert_eq!((marks[0].0.as_str(), marks[0].1.path.last(), marks[0].1.off), ("Here", 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bookmark_opened_between_a_table_and_a_chunk_starts_the_chunk() {
|
||||
let table = format!("<w:tbl><w:tr><w:tc>{}</w:tc></w:tr></w:tbl>", p("Cell"));
|
||||
let body = format!(r#"{table}<w:bookmarkStart w:id="0" w:name="Here"/>{CHUNK}<w:bookmarkEnd w:id="0"/>{}"#, p("After"));
|
||||
let bytes = package(&body, Some(("htm", "application/xhtml+xml", b"<p>Chunk</p>")));
|
||||
let import = |_: &AltChunk| Some(Document::from_text("Chunk"));
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
let marks = d.bookmarks();
|
||||
assert_eq!(marks.len(), 1);
|
||||
assert_eq!((marks[0].1.path.clone(), marks[0].1.off), (wordcraft_doc::Path::top(1), 0), "not in the table's cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_ranges_of_chunks_and_their_host_stay_apart() {
|
||||
let inner = package(r#"<w:p><w:permStart w:id="0" w:ed="them"/><w:r><w:t>Inner</w:t></w:r><w:permEnd w:id="0"/></w:p>"#, None);
|
||||
// The host's range 0 is open across two chunks whose own ranges are 0 too.
|
||||
let body = format!(
|
||||
r#"<w:p><w:permStart w:id="0" w:edGrp="everyone"/><w:r><w:t>Host</w:t></w:r></w:p>{CHUNK}{CHUNK}<w:p><w:r><w:t>After</w:t></w:r><w:permEnd w:id="0"/></w:p>"#
|
||||
);
|
||||
let d = read(&package(&body, Some(("docx", DOCX_MAIN, &inner)))).unwrap();
|
||||
assert_eq!(body_lines(&d), ["Host", "Inner", "Inner", "After"]);
|
||||
let check = |d: &Document| {
|
||||
let ranges = d.perm_ranges();
|
||||
let mut ids: Vec<u32> = ranges.iter().map(|r| r.id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 3, "{ranges:?}");
|
||||
let host = ranges.iter().find(|r| r.group == "everyone").unwrap();
|
||||
assert_eq!((host.start.path.last(), host.end.path.last()), (0, 3));
|
||||
assert!(ranges.iter().filter(|r| r.editor == "them").all(|r| r.start.path == r.end.path));
|
||||
};
|
||||
check(&d);
|
||||
check(&read(&wordcraft_docx::write(&d).unwrap()).unwrap());
|
||||
}
|
||||
|
||||
/// A package whose body is `body`, with story parts (relationship type, part name, XML) and the
|
||||
/// Word document `chunk` as `rIdC` from the main part and from each story part.
|
||||
fn package_with_stories(body: &str, stories: &[(&str, &str, String)], chunk: Option<&[u8]>) -> Vec<u8> {
|
||||
let rel = |id: &str, ty: &str, target: &str| {
|
||||
format!(r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"/>"#)
|
||||
};
|
||||
let rels =
|
||||
|inner: String| format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{inner}</Relationships>"#);
|
||||
let chunk_rel = if chunk.is_some() { rel("rIdC", "aFChunk", "/word/afchunk.docx") } else { String::new() };
|
||||
let doc = format!(r#"<w:document {NS}><w:body>{body}<w:sectPr/></w:body></w:document>"#);
|
||||
let mut main_rels = chunk_rel.clone();
|
||||
let mut files: Vec<(String, Vec<u8>)> = vec![("_rels/.rels".into(), ROOT_RELS.into()), ("word/document.xml".into(), doc.into_bytes())];
|
||||
for (i, (ty, name, xml)) in stories.iter().enumerate() {
|
||||
main_rels.push_str(&rel(&format!("rIdS{i}"), ty, name));
|
||||
files.push((format!("word/{name}"), xml.clone().into_bytes()));
|
||||
files.push((format!("word/_rels/{name}.rels"), rels(chunk_rel.clone()).into_bytes()));
|
||||
}
|
||||
files.push(("word/_rels/document.xml.rels".into(), rels(main_rels).into_bytes()));
|
||||
if let Some(c) = chunk {
|
||||
files.push(("word/afchunk.docx".into(), c.to_vec()));
|
||||
}
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(&bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A paragraph `text` that comment `id` (in the file) is anchored around.
|
||||
fn commented(id: u32, text: &str) -> String {
|
||||
format!(
|
||||
r#"<w:p><w:commentRangeStart w:id="{id}"/><w:r><w:t>{text}</w:t></w:r><w:commentRangeEnd w:id="{id}"/><w:r><w:commentReference w:id="{id}"/></w:r></w:p>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A comments part: (file id, author, content).
|
||||
fn comments_part(list: &[(u32, &str, String)]) -> (&'static str, &'static str, String) {
|
||||
let body: String = list.iter().map(|(id, who, content)| format!(r#"<w:comment w:id="{id}" w:author="{who}">{content}</w:comment>"#)).collect();
|
||||
("comments", "comments.xml", format!("<w:comments {NS}>{body}</w:comments>"))
|
||||
}
|
||||
|
||||
/// The authors of the comments anchored in the stories whose text contains `text`, and whether
|
||||
/// every comment has text of its own and a distinct id.
|
||||
fn anchored(d: &Document, text: &str) -> Vec<String> {
|
||||
let mut stories: Vec<&[std::sync::Arc<Block>]> = vec![&d.body];
|
||||
stories.extend(d.parts.values().map(|p| p.blocks.as_slice()));
|
||||
let mut out = Vec::new();
|
||||
for blocks in stories {
|
||||
for b in blocks {
|
||||
let Block::Para(p) = &**b else { continue };
|
||||
if !p.text.contains(text) {
|
||||
continue;
|
||||
}
|
||||
for o in &p.objects {
|
||||
if let InlineObject::CommentStart { id } = o {
|
||||
out.push(d.comments.get(id).map_or("?".to_string(), |c| c.author.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Word document with one comment by "Them" around "Inner".
|
||||
fn commented_chunk() -> Vec<u8> {
|
||||
package_with_stories(&commented(0, "Inner"), &[comments_part(&[(0, "Them", p("Theirs"))])], None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_in_a_footnote_keeps_its_comments_beside_the_documents() {
|
||||
let notes = format!(r#"<w:footnotes {NS}><w:footnote w:id="1"><w:altChunk r:id="rIdC"/></w:footnote></w:footnotes>"#);
|
||||
let body = format!(r#"{}<w:p><w:r><w:footnoteReference w:id="1"/></w:r></w:p>"#, commented(0, "Host"));
|
||||
let stories = [("footnotes", "footnotes.xml", notes), comments_part(&[(0, "Us", p("Ours"))])];
|
||||
let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap();
|
||||
assert_eq!(d.comments.len(), 2, "{:?}", d.comments);
|
||||
assert_eq!(anchored(&d, "Host"), ["Us"]);
|
||||
assert_eq!(anchored(&d, "Inner"), ["Them"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_in_a_comment_keeps_its_comments_beside_the_documents() {
|
||||
let inside = format!(r#"{}<w:altChunk r:id="rIdC"/>"#, p("Ours"));
|
||||
let body = format!("{}{}", commented(0, "Host"), commented(1, "Second"));
|
||||
let stories = [comments_part(&[(0, "Us", inside), (1, "Us too", p("Ours too"))])];
|
||||
let d = read(&package_with_stories(&body, &stories, Some(&commented_chunk()))).unwrap();
|
||||
assert_eq!(d.comments.len(), 3, "{:?}", d.comments);
|
||||
assert_eq!(anchored(&d, "Host"), ["Us"]);
|
||||
assert_eq!(anchored(&d, "Second"), ["Us too"]);
|
||||
assert_eq!(anchored(&d, "Inner"), ["Them"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wordprocessing_chunk_is_read_without_an_importer_and_flows_in_its_section() {
|
||||
let inner = package(&format!("{}{}", p("Inner one"), p("Inner two")), None);
|
||||
let bytes = package(&format!("{}{CHUNK}{}", p("Before"), p("After")), Some(("docx", DOCX_MAIN, &inner)));
|
||||
let d = read(&bytes).unwrap();
|
||||
assert_eq!(body_lines(&d), ["Before", "Inner one", "Inner two", "After"]);
|
||||
// The inner document's own section doesn't come along.
|
||||
assert_eq!(d.sections().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_wordprocessing_chunks_stop_at_a_depth() {
|
||||
let mut bytes = package(&p("Deepest"), None);
|
||||
for level in 0..8 {
|
||||
bytes = package(&format!("{}{CHUNK}", p(&format!("Level {level}"))), Some(("docx", DOCX_MAIN, &bytes)));
|
||||
}
|
||||
let lines = body_lines(&read(&bytes).unwrap());
|
||||
assert_eq!(lines.first().map(String::as_str), Some("Level 7"));
|
||||
assert!(!lines.iter().any(|l| l == "Deepest"), "{lines:?}");
|
||||
assert!(lines.len() >= 4, "{lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunks_pictures_come_with_it() {
|
||||
let bytes = package(&format!("{}{CHUNK}", p("Before")), Some(("htm", "text/html", b"<p>x</p>")));
|
||||
let import = |_: &AltChunk| {
|
||||
let mut d = Document::new();
|
||||
let key = d.add_media(vec![1, 2, 3], "png");
|
||||
let mut para = wordcraft_doc::Paragraph::new();
|
||||
let image = InlineObject::Image { media: key, w: 10.0, h: 10.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
para.insert_object(0, image, &Default::default()).unwrap();
|
||||
d.body = vec![std::sync::Arc::new(Block::Para(para))];
|
||||
Some(d)
|
||||
};
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
let keys: Vec<String> = d
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
InlineObject::Image { media, .. } => Some(media.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(keys.len(), 1);
|
||||
assert_eq!(d.media.get(&keys[0]).map(|m| m.as_slice()), Some(&[1u8, 2, 3][..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_brings_in_a_bounded_number_of_chunks() {
|
||||
let bytes = package(&CHUNK.repeat(300), Some(("htm", "text/html", b"<p>x</p>")));
|
||||
let calls = std::cell::Cell::new(0);
|
||||
let import = |_: &AltChunk| {
|
||||
calls.set(calls.get() + 1);
|
||||
Some(Document::from_text("x"))
|
||||
};
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
assert_eq!(calls.get(), 256);
|
||||
assert_eq!(body_lines(&d).len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_chunks_never_panic() {
|
||||
for (ext, ct, bytes) in [("docx", DOCX_MAIN, &b"PK\x03\x04 not a zip"[..]), ("htm", "text/html", b""), ("bin", "", b"\x00\xff")] {
|
||||
let bytes = package(&format!("{CHUNK}{CHUNK}{}", p("x")), Some((ext, ct, bytes)));
|
||||
let import = |_: &AltChunk| None;
|
||||
let d = read_with(&bytes, &ReadOptions { chunks: Some(&import), ..Default::default() }).unwrap();
|
||||
assert_eq!(body_lines(&d), ["x"]);
|
||||
}
|
||||
// A chunk naming a missing part or another relationship type is left out.
|
||||
let bytes = package(&format!(r#"<w:altChunk r:id="nope"/><w:altChunk/>{}"#, p("y")), None);
|
||||
assert_eq!(body_lines(&read(&bytes).unwrap()), ["y"]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod alt_chunks;
|
||||
mod bidi;
|
||||
mod bidi_runs;
|
||||
mod cell_margin_inheritance;
|
||||
|
||||
@@ -134,7 +134,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("insert.textFromFile", "Text from File", "Insert › Text › Object", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
let mut frag = doc.body_fragment();
|
||||
// The file's pictures, lists and styles come along (its own keys and ids mean nothing here).
|
||||
let mut frag = s.doc.adopt(doc);
|
||||
super::paste::unlink_sections(&mut frag.blocks);
|
||||
let at = delete_selection(s)?;
|
||||
let end = s.doc.insert_fragment(&at, &frag)?;
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
//! program's HTML or RTF; Merge Formatting keeps only its emphasis (#687).
|
||||
//! Plain text has no formatting to keep, so Paste inserts it as Keep Text Only does.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -401,35 +400,6 @@ pub(crate) fn each_para(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Para
|
||||
}
|
||||
}
|
||||
|
||||
/// Point a tracked formatting change at its renumbered revision (dropped when it has none).
|
||||
fn remap_change<T>(c: &mut Option<Box<wordcraft_doc::props::PropChange<T>>>, rev: &dyn Fn(&mut Option<u32>)) {
|
||||
let mut r = c.as_ref().map(|c| c.rev);
|
||||
rev(&mut r);
|
||||
match (r, c.as_mut()) {
|
||||
(Some(v), Some(x)) => x.rev = v,
|
||||
_ => *c = None,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`remap_change`] for the table, row and cell formatting changes in `b` (bounded depth).
|
||||
fn remap_table_changes(b: &mut Block, depth: usize, rev: &dyn Fn(&mut Option<u32>)) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if let Block::Table(t) = b {
|
||||
remap_change(&mut t.props.fmt_change, rev);
|
||||
for row in &mut t.rows {
|
||||
remap_change(&mut row.props.fmt_change, rev);
|
||||
for cell in &mut row.cells {
|
||||
remap_change(&mut cell.props.fmt_change, rev);
|
||||
for cb in &mut cell.blocks {
|
||||
remap_table_changes(Arc::make_mut(cb), depth + 1, rev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Section breaks brought in from another document keep their page, but their header and
|
||||
/// footer ids are that document's: clear them (and the different first page that went with
|
||||
/// them) so the sections link to the previous section's headers and footers, as in Word.
|
||||
@@ -448,81 +418,12 @@ pub(crate) fn unlink_sections(blocks: &mut [Block]) {
|
||||
/// Turn an imported document into a fragment of the session's document: bring over the media,
|
||||
/// lists and styles it refers to (renumbered so they can't collide with ours).
|
||||
fn adopt(s: &mut Session, src: Document) -> Fragment {
|
||||
let mut media = BTreeMap::new();
|
||||
for (key, data) in &src.media {
|
||||
let ext = key.rsplit_once('.').map(|(_, e)| e).unwrap_or("png");
|
||||
media.insert(key.clone(), s.doc.add_media(data.to_vec(), ext));
|
||||
}
|
||||
let next_abs = s.doc.numbering.abstracts.iter().map(|a| a.id.saturating_add(1)).max().unwrap_or(0);
|
||||
let mut abs_map = BTreeMap::new();
|
||||
for (i, a) in src.numbering.abstracts.iter().enumerate() {
|
||||
let id = next_abs.saturating_add(i as u32);
|
||||
abs_map.insert(a.id, id);
|
||||
s.doc.numbering.abstracts.push(wordcraft_doc::numbering::AbstractNum { id, ..a.clone() });
|
||||
}
|
||||
let next_num = s.doc.numbering.nums.iter().map(|n| n.id.saturating_add(1)).max().unwrap_or(1).max(1);
|
||||
let mut num_map = BTreeMap::new();
|
||||
for (i, n) in src.numbering.nums.iter().enumerate() {
|
||||
let id = next_num.saturating_add(i as u32);
|
||||
num_map.insert(n.id, id);
|
||||
let abstract_id = abs_map.get(&n.abstract_id).copied().unwrap_or(n.abstract_id);
|
||||
s.doc.numbering.nums.push(wordcraft_doc::numbering::Num { id, abstract_id, ..n.clone() });
|
||||
}
|
||||
for st in &src.styles.styles {
|
||||
if s.doc.styles.get(&st.id).is_none() {
|
||||
s.doc.styles.upsert(st.clone());
|
||||
}
|
||||
}
|
||||
// Tracked changes the source carries (RTF revisions) point at its own revisions: add them
|
||||
// after ours and renumber.
|
||||
let rev_base = u32::try_from(s.doc.revisions.len()).unwrap_or(u32::MAX);
|
||||
let src_revs = src.revisions.len();
|
||||
s.doc.revisions.extend(src.revisions.iter().cloned());
|
||||
let rev = |i: &mut Option<u32>| {
|
||||
*i = i.filter(|v| (*v as usize) < src_revs).and_then(|v| rev_base.checked_add(v));
|
||||
};
|
||||
let mut blocks: Vec<Block> = src.body.iter().map(|b| (**b).clone()).collect();
|
||||
let mut frag = s.doc.adopt(src);
|
||||
// Pasted pictures fit our text width, like Insert › Pictures (layout keeps their size).
|
||||
let max_w = crate::io::text_width(&s.doc);
|
||||
for b in &mut blocks {
|
||||
for b in &mut frag.blocks {
|
||||
crate::io::fit_block_pictures(b, max_w);
|
||||
}
|
||||
for b in &mut blocks {
|
||||
each_para(b, 0, &mut |p| {
|
||||
if let Some(n) = p.props.numbering.as_mut()
|
||||
&& n.num != 0
|
||||
{
|
||||
n.num = num_map.get(&n.num).copied().unwrap_or(0);
|
||||
}
|
||||
for r in &mut p.runs {
|
||||
rev(&mut r.props.ins);
|
||||
rev(&mut r.props.del);
|
||||
remap_change(&mut r.props.fmt_change, &rev);
|
||||
}
|
||||
rev(&mut p.mark.ins);
|
||||
rev(&mut p.mark.del);
|
||||
remap_change(&mut p.mark.fmt_change, &rev);
|
||||
remap_change(&mut p.props.fmt_change, &rev);
|
||||
let mut n = p.props.num_change.as_ref().map(|c| c.rev);
|
||||
rev(&mut n);
|
||||
match (n, p.props.num_change.as_mut()) {
|
||||
(Some(v), Some(c)) => c.rev = v,
|
||||
_ => p.props.num_change = None,
|
||||
}
|
||||
for o in &mut p.objects {
|
||||
if let wordcraft_doc::InlineObject::Image { media: key, .. } = o
|
||||
&& let Some(k) = media.get(key.as_str())
|
||||
{
|
||||
*key = k.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for b in &mut blocks {
|
||||
remap_table_changes(b, 0, &rev);
|
||||
}
|
||||
unlink_sections(&mut blocks);
|
||||
// Text box stories the imported shapes show; pasting gives each a new story of its own.
|
||||
let parts = src.parts.into_iter().filter(|(_, p)| p.kind == wordcraft_doc::PartKind::TextBox).collect();
|
||||
Fragment { blocks, parts }
|
||||
unlink_sections(&mut frag.blocks);
|
||||
frag
|
||||
}
|
||||
+47
-4
@@ -18,6 +18,8 @@ pub const OPEN_EXTS: &[&str] = &[
|
||||
"markdown",
|
||||
"html",
|
||||
"htm",
|
||||
"mht",
|
||||
"mhtml",
|
||||
"rtf",
|
||||
"odt",
|
||||
"tex",
|
||||
@@ -201,7 +203,7 @@ fn load_embedded_fonts(doc: &Document) {
|
||||
fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft_doc::SectionProps) -> Result<(Document, bool), String> {
|
||||
let ext = ext_of(name);
|
||||
if matches!(ext.as_str(), "docx" | "docm" | "dotx" | "dotm" | "doc" | "dot") && wordcraft_docx::is_encrypted(bytes) {
|
||||
let mut doc = wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?;
|
||||
let mut doc = read_docx(bytes, password).map_err(|e| e.to_string())?;
|
||||
opened(&mut doc);
|
||||
return Ok((doc, true));
|
||||
}
|
||||
@@ -230,6 +232,42 @@ fn read_bytes(name: &str, bytes: &[u8], password: Option<&str>, page: &wordcraft
|
||||
Ok((doc, false))
|
||||
}
|
||||
|
||||
/// Read a Word package (decrypted with `password` if it is encrypted), bringing in its
|
||||
/// alternative format chunks (`w:altChunk`) as Word does when it opens one: see [`import_chunk`].
|
||||
pub(crate) fn read_docx(bytes: &[u8], password: Option<&str>) -> Result<Document, wordcraft_docx::DocxError> {
|
||||
wordcraft_docx::read_with(bytes, &wordcraft_docx::ReadOptions { password, chunks: Some(&import_chunk) })
|
||||
}
|
||||
|
||||
/// An alternative format chunk in a format the docx reader leaves to the format importers, as a
|
||||
/// document: an MHT web archive, HTML, RTF or plain text, told by its content type, its part's
|
||||
/// extension or what it holds. Its pictures fit the text width, as when the file is opened.
|
||||
fn import_chunk(c: &wordcraft_docx::AltChunk) -> Option<Document> {
|
||||
use wordcraft_formats::{html, mht, rtf, txt};
|
||||
let full = c.content_type.to_ascii_lowercase();
|
||||
let ct = full.split(';').next().unwrap_or("").trim();
|
||||
let charset = full.split(';').skip(1).find_map(|p| p.trim().strip_prefix("charset=")).map(|v| v.trim_matches('"'));
|
||||
let ext = ext_of(c.part);
|
||||
let b = c.bytes;
|
||||
let mut doc = if matches!(ct, "message/rfc822" | "application/x-mimearchive")
|
||||
|| ct.starts_with("multipart/")
|
||||
|| matches!(ext.as_str(), "mht" | "mhtml")
|
||||
|| mht::is_mht(b)
|
||||
{
|
||||
mht::import(b)
|
||||
} else if ct.contains("rtf") || ext == "rtf" || b.starts_with(b"{\\rtf") {
|
||||
rtf::import(b).ok()?
|
||||
} else if ct.contains("html") || matches!(ext.as_str(), "html" | "htm" | "xhtml") {
|
||||
html::import_page(&html::decode_as(b, charset), &|_| None)
|
||||
} else if ct == "text/plain" || ext == "txt" {
|
||||
txt::import(b)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
// shortcut: pictures fit the chunk's default page, not the section it lands in; fit them there if wide pictures in chunks overflow narrow sections.
|
||||
fit_pictures(&mut doc);
|
||||
Some(doc)
|
||||
}
|
||||
|
||||
/// The narrowest text width of the document's sections, points: what a picture must fit.
|
||||
pub(crate) fn text_width(doc: &Document) -> f32 {
|
||||
doc.sections().iter().map(|(_, sp)| sp.text_width()).fold(f32::INFINITY, f32::min).clamp(36.0, 4000.0)
|
||||
@@ -270,7 +308,7 @@ pub(crate) fn fit_pictures(doc: &mut Document) {
|
||||
/// Does the file hold a different document format than its Word or RTF extension says? An RTF
|
||||
/// file saved as `.doc` (as accounting systems and report generators often save their
|
||||
/// documents), a `.docx` package renamed `.doc`, a Word 97-2003 file renamed `.docx`, a Word XML
|
||||
/// Document (2003 or Flat OPC) or a web page saved as `.doc` (#2853): Word opens
|
||||
/// Document (2003 or Flat OPC) or a web page (HTML or MHT) saved as `.doc` (#2853): Word opens
|
||||
/// each by what it holds, so they go through [`open_by_content`] instead of failing.
|
||||
fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
|
||||
let expected = match ext {
|
||||
@@ -289,6 +327,8 @@ fn signature_contradicts(ext: &str, bytes: &[u8]) -> bool {
|
||||
"xml"
|
||||
} else if looks_like_web_page(bytes) {
|
||||
"html"
|
||||
} else if wordcraft_formats::mht::is_mht(bytes) {
|
||||
"mht"
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
@@ -346,9 +386,9 @@ fn open_by_content(
|
||||
page: &wordcraft_doc::SectionProps,
|
||||
) -> Result<(Document, bool), String> {
|
||||
let (mut doc, encrypted) = if bytes.starts_with(OLE_SIG) && wordcraft_docx::is_encrypted(bytes) {
|
||||
(wordcraft_docx::read_with_password(bytes, password).map_err(|e| e.to_string())?, true)
|
||||
(read_docx(bytes, password).map_err(|e| e.to_string())?, true)
|
||||
} else if bytes.starts_with(ZIP_SIG) {
|
||||
(wordcraft_docx::read(bytes).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||||
(read_docx(bytes, None).map_err(|e| format!("{name}: not a document WordCraft can open ({e})"))?, false)
|
||||
} else if bytes.starts_with(OLE_SIG) {
|
||||
open_binary_doc(bytes, password)
|
||||
.map_err(|e| if needs_password(&e) || wrong_password(&e) { e } else { format!("{name}: not a document WordCraft can open ({e})") })?
|
||||
@@ -359,6 +399,9 @@ fn open_by_content(
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && looks_like_web_page(bytes) {
|
||||
let opened = crate::io_ext::open("html", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web pages")));
|
||||
(opened?, false)
|
||||
} else if matches!(ext, "doc" | "dot" | "docx" | "docm" | "dotx" | "dotm" | "rtf") && wordcraft_formats::mht::is_mht(bytes) {
|
||||
let opened = crate::io_ext::open("mht", bytes, page).unwrap_or_else(|| Err(format!("{name}: can't open web archives")));
|
||||
(opened?, false)
|
||||
} else if wordcraft_formats::txt::is_text(bytes) {
|
||||
(wordcraft_formats::txt::import_on(bytes, page), false)
|
||||
} else if ext.is_empty() {
|
||||
|
||||
@@ -6,7 +6,7 @@ use wordcraft_doc::Document;
|
||||
/// start on `page`.
|
||||
pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
|
||||
match ext {
|
||||
"docx" | "docm" | "dotx" | "dotm" => Some(wordcraft_docx::read(bytes).map_err(|e| e.to_string())),
|
||||
"docx" | "docm" | "dotx" | "dotm" => Some(crate::io::read_docx(bytes, None).map_err(|e| e.to_string())),
|
||||
"doc" | "dot" => Some(wordcraft_docbin::read(bytes).map_err(|e| e.to_string())),
|
||||
// Word XML Document (Flat OPC) or Word 2003 XML, told apart by the root element.
|
||||
"xml" => Some(wordcraft_docx::read_xml(bytes).map_err(|e| e.to_string())),
|
||||
|
||||
@@ -11599,3 +11599,140 @@ fn export_png_rejects_a_page_number_that_does_not_fit() {
|
||||
assert!(!target.exists(), "no file is written for an invalid page");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A Word package whose body holds `body` and, as `rIdC`, the chunk part `word/afchunk.{ext}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn docx_with_chunk(body: &str, ext: &str, content_type: &str, chunk: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
let files: [(String, Vec<u8>); 4] = [
|
||||
(
|
||||
"[Content_Types].xml".into(),
|
||||
format!(r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="{ext}" ContentType="{content_type}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#).into_bytes(),
|
||||
),
|
||||
("_rels/.rels".into(), br#"<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"/></Relationships>"#.to_vec()),
|
||||
(
|
||||
"word/_rels/document.xml.rels".into(),
|
||||
format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdC" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/aFChunk" Target="afchunk.{ext}"/></Relationships>"#).into_bytes(),
|
||||
),
|
||||
(
|
||||
"word/document.xml".into(),
|
||||
format!(r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#).into_bytes(),
|
||||
),
|
||||
];
|
||||
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (name, bytes) in files.iter().map(|(n, b)| (n.as_str(), b.as_slice())).chain([(format!("word/afchunk.{ext}").as_str(), chunk)]) {
|
||||
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zw.write_all(bytes).unwrap();
|
||||
}
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// A Single File Web Page under a Word or RTF name opens as one, like the web pages of #2853.
|
||||
#[test]
|
||||
fn a_single_file_web_page_saved_as_doc_opens_by_content() {
|
||||
let mht = "MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n<p>Izvje\u{161}taj</p>\r\n--B--\r\n";
|
||||
for name in ["report.doc", "report.docx", "report.rtf"] {
|
||||
let doc = crate::io::open_bytes(name, mht.as_bytes()).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(doc.plain_text(StoryRef::Body), "Izvje\u{161}taj", "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn alt_chunks_open_in_place_as_word_brings_them_in() {
|
||||
let png = wordcraft_formats::model::base64_encode(&tiny_png());
|
||||
let mht = format!(
|
||||
"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"B\"\r\n\r\n--B\r\nContent-Location: file:///C:/doc.htm\r\n\
|
||||
Content-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\
|
||||
<style>p.c {{ text-align: center }}</style><p class=3D\"c\">=C4=8Clanak 1.</p><p><img src=3D\"doc_files/a.png\"></p>\r\n\
|
||||
--B\r\nContent-Location: file:///C:/doc_files/a.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{png}\r\n--B--\r\n"
|
||||
);
|
||||
let p = |t: &str| format!(r#"<w:p><w:r><w:t>{t}</w:t></w:r></w:p>"#);
|
||||
let chunk = r#"<w:altChunk r:id="rIdC"/>"#;
|
||||
let bytes = docx_with_chunk(&format!("{}{chunk}{}", p("Before"), p("After")), "mht", "message/rfc822", mht.as_bytes());
|
||||
let doc = crate::io::open_bytes("chunk.docx", &bytes).unwrap();
|
||||
let text = doc.plain_text(StoryRef::Body);
|
||||
assert!(text.starts_with("Before\nČlanak 1.\n") && text.ends_with("After"), "{text:?}");
|
||||
let para = doc.para(StoryRef::Body, &wordcraft_doc::Path::top(1)).unwrap();
|
||||
assert_eq!(para.props.align, Some(wordcraft_doc::Align::Center));
|
||||
// Web page text: Times New Roman unless the page says otherwise.
|
||||
assert_eq!(para.props_of_char(0).font.as_deref(), Some("Times New Roman"));
|
||||
// The picture came along, its bytes under the key it shows.
|
||||
let key = doc
|
||||
.body
|
||||
.iter()
|
||||
.find_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the chunk's picture");
|
||||
assert_eq!(doc.media.get(&key).map(|m| m.to_vec()), Some(tiny_png()));
|
||||
|
||||
// HTML (in its content type's charset), RTF and plain-text chunks come in through their
|
||||
// importers too, and an MHT chunk is known by what it holds.
|
||||
for (ext, ct, data, want) in [
|
||||
("htm", "text/html; charset=windows-1250", &b"<p>\xc8lanak chunk</p>"[..], "Članak chunk"),
|
||||
("rtf", "application/rtf", br"{\rtf1\ansi Rich chunk\par}", "Rich chunk"),
|
||||
("txt", "text/plain", b"Plain chunk", "Plain chunk"),
|
||||
("bin", "", mht.as_bytes(), "Članak 1."),
|
||||
] {
|
||||
let bytes = docx_with_chunk(&format!("{chunk}{}", p("End")), ext, ct, data);
|
||||
let text = crate::io::open_bytes("chunk.docx", &bytes).unwrap().plain_text(StoryRef::Body);
|
||||
assert!(text.starts_with(want) && text.ends_with("End"), "{ext}: {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn text_from_file_brings_the_files_pictures() {
|
||||
let dir = scratch_dir("text-from-file-pictures");
|
||||
let mut src = Session::new(wordcraft_doc::Document::new());
|
||||
let list = src.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Bullet);
|
||||
// A list item, then a paragraph (a single paragraph would take the caret's formatting).
|
||||
src.doc.split_paragraph(&Pos::body(0, 0)).unwrap();
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().props.numbering =
|
||||
Some(wordcraft_doc::props::NumRef { num: list, level: 0 });
|
||||
let key = src.doc.add_media(tiny_png(), "png");
|
||||
let image =
|
||||
wordcraft_doc::InlineObject::Image { media: key, w: 20.0, h: 20.0, alt: String::new(), float: Default::default(), crop: [0.0; 4], ole: None };
|
||||
src.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap().insert_object(0, image, &Default::default()).unwrap();
|
||||
let path = dir.join("pictures.docx");
|
||||
std::fs::write(&path, wordcraft_docx::write(&src.doc).unwrap()).unwrap();
|
||||
|
||||
let mut s = s();
|
||||
// A picture and a list of our own first, so the file's key and list id would point at ours.
|
||||
let ours = s.doc.add_media(vec![9, 9, 9], "png");
|
||||
let our_list = s.doc.numbering.add_list(wordcraft_doc::numbering::ListKind::Numbered);
|
||||
run(&mut s, "insert.textFromFile", json!({"path": path.to_string_lossy()}));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let shown: Vec<String> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.objects.iter().find_map(|o| match o {
|
||||
wordcraft_doc::InlineObject::Image { media, .. } => Some(media.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(shown.len(), 1);
|
||||
assert_ne!(shown[0], ours);
|
||||
assert_eq!(s.doc.media.get(&shown[0]).map(|m| m.to_vec()), Some(tiny_png()));
|
||||
let listed: Vec<u32> = s
|
||||
.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| match &**b {
|
||||
wordcraft_doc::Block::Para(p) => p.props.numbering.map(|n| n.num),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_ne!(listed[0], our_list);
|
||||
assert!(s.doc.numbering.level(listed[0], 0).is_some_and(|l| l.format == wordcraft_doc::section::NumFormat::Bullet));
|
||||
}
|
||||
@@ -8,18 +8,22 @@
|
||||
//! (`rb`/`rt`/`rp`: furigana and other phonetic guides, #1681), and the CSS
|
||||
//! properties `color`, `background-color`, `font-weight`, `font-style`, `font-size`,
|
||||
//! `font-family`, `text-decoration`, `vertical-align`, `text-align`, `white-space` and page
|
||||
//! breaks. Scripts and styles are skipped; nesting is capped.
|
||||
//! breaks, and on paragraphs `margin`, `text-indent` and `line-height`. CSS comes from `style`
|
||||
//! attributes and from `style` elements' rules with simple selectors (`p`, `.note`, `p.note`),
|
||||
//! applied in cascade order (specificity, then source order, then the `style` attribute).
|
||||
//! Scripts are skipped; nesting is capped.
|
||||
//!
|
||||
//! Export writes clean semantic HTML with inline CSS, pictures as `data:` URIs and ruby as
|
||||
//! `<ruby>`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::LineSpacing;
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document};
|
||||
|
||||
use crate::model::{
|
||||
self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, base64_encode, data_uri, make_img, mime_of, parse_color,
|
||||
self, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, Spacing, base64_encode, data_uri, make_img, mime_of, parse_color,
|
||||
};
|
||||
|
||||
mod builder;
|
||||
@@ -32,9 +36,11 @@ mod math_ruby;
|
||||
|
||||
pub use builder::ImageLoader;
|
||||
pub use css::css_len_pt;
|
||||
pub use entity::{decode, decode_entity, unescape};
|
||||
pub use entity::{decode, decode_as, decode_entity, unescape};
|
||||
pub use export::{esc, export, export_flow, export_fragment, inlines_html};
|
||||
pub use import::{import, import_on, import_with, parse, parse_with};
|
||||
#[cfg(test)]
|
||||
use import::parse_in;
|
||||
pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::css::{apply_style, css_len_pt};
|
||||
use super::import::collapse_ws;
|
||||
use super::{Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, VAlign, data_uri, make_img, model, parse_color};
|
||||
use super::css::{apply_style, css_len_pt, strip_important};
|
||||
use super::import::{PAGE_FONT, PAGE_HEADING_SIZES, collapse_ws};
|
||||
use super::{
|
||||
Align, Arc, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, LineSpacing, ListInfo, Meta, Para, Spacing, VAlign, data_uri, make_img, model,
|
||||
parse_color,
|
||||
};
|
||||
|
||||
/// Open elements kept at most (deeper start tags are ignored).
|
||||
const MAX_STACK: usize = 256;
|
||||
@@ -28,6 +31,12 @@ struct El {
|
||||
name: String,
|
||||
kind: ElKind,
|
||||
fmt: Fmt,
|
||||
/// Margins, indents and line height its CSS states (blocks only; they don't inherit; on a
|
||||
/// page's spans, their margins).
|
||||
spacing: Spacing,
|
||||
/// A page's `p` or heading: margins its CSS leaves out (or writes invalidly) are HTML's
|
||||
/// automatic spacing.
|
||||
auto_margins: bool,
|
||||
align: Option<Align>,
|
||||
preserve: bool,
|
||||
page_break: bool,
|
||||
@@ -77,20 +86,37 @@ fn apply_font_attributes(attrs: &[(String, String)], fmt: &mut Fmt) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an element's `border` attribute or `style` declares a visible border.
|
||||
fn declares_border(attrs: &[(String, String)], is_table: bool) -> bool {
|
||||
/// Whether an element's `border` attribute or its CSS declares a visible border.
|
||||
fn declares_border(attrs: &[(String, String)], css: &str, is_table: bool) -> bool {
|
||||
if is_table && attr(attrs, "border").is_some_and(|b| b.trim().parse::<u32>().map(|n| n > 0).unwrap_or(b.trim().is_empty())) {
|
||||
return true;
|
||||
}
|
||||
let Some(style) = attr(attrs, "style") else { return false };
|
||||
style.split(';').any(|decl| {
|
||||
let Some((name, value)) = decl.split_once(':') else { return false };
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if !matches!(name.as_str(), "border" | "border-top" | "border-right" | "border-bottom" | "border-left") {
|
||||
return false;
|
||||
// Each side's last declaration decides (the `style` attribute's come after the sheet's).
|
||||
let mut sides = [false; 4];
|
||||
for (name, value) in decls(css) {
|
||||
let set = match name.as_str() {
|
||||
"border" => 0..4,
|
||||
"border-top" => 0..1,
|
||||
"border-right" => 1..2,
|
||||
"border-bottom" => 2..3,
|
||||
"border-left" => 3..4,
|
||||
_ => continue,
|
||||
};
|
||||
let value = value.to_ascii_lowercase();
|
||||
let on = !value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt"));
|
||||
if let Some(s) = sides.get_mut(set) {
|
||||
s.fill(on);
|
||||
}
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
!value.is_empty() && !value.split_whitespace().any(|tok| matches!(tok, "none" | "hidden" | "0" | "0px" | "0pt"))
|
||||
}
|
||||
sides.contains(&true)
|
||||
}
|
||||
|
||||
/// A declaration block's declarations, at most [`MAX_DECLS`]: (property in lower case, value
|
||||
/// without `!important`).
|
||||
pub(super) fn decls(css: &str) -> impl Iterator<Item = (String, &str)> {
|
||||
css.split(';').take(MAX_DECLS).filter_map(|d| {
|
||||
let (k, v) = d.split_once(':')?;
|
||||
Some((k.trim().to_ascii_lowercase(), strip_important(v)))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +142,12 @@ pub(super) struct Builder<'r> {
|
||||
pre_tail: bool,
|
||||
meta: Meta,
|
||||
title: Option<String>,
|
||||
/// Rules of the page's `style` elements.
|
||||
pub(super) sheet: Sheet,
|
||||
/// Read as Word opens a web page ([`import_page`]), not as a pasted fragment.
|
||||
page: bool,
|
||||
/// What is left of [`MAX_CSS_WORK`].
|
||||
css_work: usize,
|
||||
}
|
||||
|
||||
fn is_void(n: &str) -> bool {
|
||||
@@ -196,12 +228,310 @@ fn closes_p(n: &str) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// The paragraph spacing a block's CSS states: `margin` (and its sides) as space before / after
|
||||
/// and left / right indents, `text-indent` as the first-line indent, `line-height` as line
|
||||
/// spacing (a number or percentage: a multiple; a length: exactly). `auto` and values that don't
|
||||
/// parse state nothing.
|
||||
fn block_spacing(css: &str) -> Spacing {
|
||||
let mut sp = Spacing::default();
|
||||
// A length with a unit, or 0: a bare number, a percentage or a keyword states nothing.
|
||||
let len = |v: &str| match v.trim() {
|
||||
"0" => Some(0.0),
|
||||
v if v.ends_with('%') || v.parse::<f32>().is_ok() || !v.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '.' | '-' | '+')) => None,
|
||||
v => css_len_pt(v).filter(|x| x.abs() <= 1584.0),
|
||||
};
|
||||
for (k, v) in decls(css) {
|
||||
match k.as_str() {
|
||||
"margin" => {
|
||||
let parts: Vec<Option<f32>> = v.split_whitespace().map(len).collect();
|
||||
let [top, right, bottom, left] = match parts.as_slice() {
|
||||
[a] => [*a; 4],
|
||||
[a, b] => [*a, *b, *a, *b],
|
||||
[a, b, c] => [*a, *b, *c, *b],
|
||||
[a, b, c, d] => [*a, *b, *c, *d],
|
||||
_ => continue,
|
||||
};
|
||||
sp.before = top.map(|x| x.max(0.0)).or(sp.before);
|
||||
sp.after = bottom.map(|x| x.max(0.0)).or(sp.after);
|
||||
sp.left = left.or(sp.left);
|
||||
sp.right = right.or(sp.right);
|
||||
}
|
||||
"margin-top" => sp.before = len(v).map(|x| x.max(0.0)).or(sp.before),
|
||||
"margin-bottom" => sp.after = len(v).map(|x| x.max(0.0)).or(sp.after),
|
||||
"margin-left" => sp.left = len(v).or(sp.left),
|
||||
"margin-right" => sp.right = len(v).or(sp.right),
|
||||
"text-indent" => sp.first = len(v).or(sp.first),
|
||||
"line-height" => {
|
||||
let lv = v.to_ascii_lowercase();
|
||||
let multiple = |m: f32| (m.is_finite() && m > 0.0).then(|| LineSpacing::Multiple(m.clamp(0.25, 10.0)));
|
||||
sp.line = if let Some(pc) = lv.strip_suffix('%') {
|
||||
pc.trim().parse::<f32>().ok().and_then(|p| multiple(p / 100.0))
|
||||
} else if let Ok(m) = lv.parse::<f32>() {
|
||||
multiple(m)
|
||||
} else if lv == "normal" {
|
||||
Some(LineSpacing::Multiple(1.0))
|
||||
} else {
|
||||
len(&lv).filter(|x| *x > 0.0).map(LineSpacing::Exactly)
|
||||
}
|
||||
.or(sp.line);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
sp
|
||||
}
|
||||
|
||||
/// A rule of a `style` element with a simple selector: an element name, classes or both.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Rule {
|
||||
tag: Option<String>,
|
||||
classes: Vec<String>,
|
||||
/// Classes count ten, an element name one.
|
||||
specificity: usize,
|
||||
/// Shared by the selectors of one block.
|
||||
decls: Arc<str>,
|
||||
/// How many declarations `decls` holds.
|
||||
count: usize,
|
||||
}
|
||||
|
||||
/// Most rules kept from a page's style elements.
|
||||
const MAX_RULES: usize = 10_000;
|
||||
/// Most declarations read for one element (its rules' and its `style` attribute's).
|
||||
const MAX_DECLS: usize = 512;
|
||||
/// Most declarations read from one `style` attribute.
|
||||
const INLINE_DECLS: usize = 64;
|
||||
/// Rule matching (rules looked at plus bytes gathered) one page may spend.
|
||||
const MAX_CSS_WORK: usize = 64 << 20;
|
||||
/// Most bytes of declarations gathered from the rules one element matches.
|
||||
const MAX_RULE_CSS: usize = 64 * 1024;
|
||||
/// Most of an element's classes looked up.
|
||||
const MAX_CLASSES: usize = 32;
|
||||
|
||||
/// A page's style rules, found by the element name or a class they need.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Sheet {
|
||||
rules: Vec<Rule>,
|
||||
/// Rules naming classes, by their first class.
|
||||
by_class: std::collections::HashMap<String, Vec<usize>>,
|
||||
/// Rules naming only an element, by its name.
|
||||
by_tag: std::collections::HashMap<String, Vec<usize>>,
|
||||
/// `*` rules.
|
||||
any: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
fn push(&mut self, r: Rule) {
|
||||
let i = self.rules.len();
|
||||
match (r.classes.first(), &r.tag) {
|
||||
(Some(c), _) => self.by_class.entry(c.clone()).or_default().push(i),
|
||||
(None, Some(t)) => self.by_tag.entry(t.clone()).or_default().push(i),
|
||||
(None, None) => self.any.push(i),
|
||||
}
|
||||
self.rules.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the rules of a style sheet into `out`. At-rules (`@media`, `@page`…) and rules whose
|
||||
/// selectors aren't simple (descendants, pseudo-classes, attributes…) are skipped.
|
||||
pub(super) fn parse_sheet(css: &str, out: &mut Sheet) {
|
||||
// Comments go, as do the `<!-- -->` old pages hide their styles in.
|
||||
let mut text = String::with_capacity(css.len());
|
||||
let mut rest = css;
|
||||
while let Some(i) = rest.find("/*") {
|
||||
text.push_str(rest.get(..i).unwrap_or(""));
|
||||
rest = rest.get(i + 2..).and_then(|r| r.find("*/").and_then(|j| r.get(j + 2..))).unwrap_or("");
|
||||
}
|
||||
text.push_str(rest);
|
||||
let text = text.replace("<!--", " ").replace("-->", " ");
|
||||
let mut rest = text.as_str();
|
||||
while let Some(open) = rest.find('{') {
|
||||
// A statement at-rule before it (`@charset "x";`, `@import url(a);`) isn't a selector.
|
||||
let selectors = rest.get(..open).unwrap_or("").rsplit(';').next().unwrap_or("").trim();
|
||||
let body = rest.get(open + 1..).unwrap_or("");
|
||||
// The block's end, counting nested braces (an at-rule's own rules).
|
||||
let mut depth = 1usize;
|
||||
let mut end = body.len();
|
||||
for (i, c) in body.char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let decls = body.get(..end).unwrap_or("");
|
||||
rest = body.get(end + 1..).unwrap_or("");
|
||||
if selectors.starts_with('@') || decls.contains('{') || decls.len() > MAX_RULE_CSS {
|
||||
continue;
|
||||
}
|
||||
let (count, decls) = (decls.split(';').count(), Arc::<str>::from(decls));
|
||||
for sel in selectors.split(',') {
|
||||
if out.rules.len() >= MAX_RULES {
|
||||
return;
|
||||
}
|
||||
if let Some((tag, classes)) = simple_selector(sel.trim()) {
|
||||
let specificity = classes.len() * 10 + usize::from(tag.is_some());
|
||||
out.push(Rule { tag, classes, specificity, decls: decls.clone(), count });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `p`, `.note`, `p.note.wide` or `*`: (element name, classes). Class names may start with a
|
||||
/// digit (`.000003`): browsers drop such a rule, but Word's HTML import applies it, and
|
||||
/// converters write them.
|
||||
fn simple_selector(sel: &str) -> Option<(Option<String>, Vec<String>)> {
|
||||
if sel == "*" {
|
||||
return Some((None, Vec::new()));
|
||||
}
|
||||
let mut parts = sel.split('.');
|
||||
let tag = parts.next()?;
|
||||
let tag = if tag.is_empty() {
|
||||
None
|
||||
} else if tag.chars().all(|c| c.is_ascii_alphanumeric()) && tag.starts_with(|c: char| c.is_ascii_alphabetic()) {
|
||||
Some(tag.to_ascii_lowercase())
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let ident = |c: &str| !c.is_empty() && c.chars().all(|ch| ch.is_alphanumeric() || ch == '_' || ch == '-');
|
||||
let classes: Vec<String> = parts.map(str::to_string).collect();
|
||||
if classes.iter().any(|c| !ident(c)) || (tag.is_none() && classes.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
Some((tag, classes))
|
||||
}
|
||||
|
||||
impl Builder<'_> {
|
||||
/// An element's CSS: the declarations of the style sheet rules it matches, in cascade order,
|
||||
/// then its `style` attribute (later declarations win).
|
||||
fn css_of(&mut self, name: &str, attrs: &[(String, String)]) -> String {
|
||||
// The `style` attribute's first declarations always fit in what is read of the result.
|
||||
let inline: Vec<&str> = attr(attrs, "style").unwrap_or("").split(';').take(INLINE_DECLS).collect();
|
||||
let inline = inline.join(";");
|
||||
let sheet = &self.sheet;
|
||||
if sheet.rules.is_empty() || self.css_work == 0 {
|
||||
return important_last(&inline);
|
||||
}
|
||||
let mut classes: Vec<&str> = attr(attrs, "class").map(|c| c.split_whitespace().take(MAX_CLASSES).collect()).unwrap_or_default();
|
||||
classes.sort_unstable();
|
||||
classes.dedup();
|
||||
let mut candidates: Vec<usize> = sheet.any.clone();
|
||||
candidates.extend(sheet.by_tag.get(name).into_iter().flatten());
|
||||
for c in &classes {
|
||||
candidates.extend(sheet.by_class.get(*c).into_iter().flatten());
|
||||
}
|
||||
let mut hits: Vec<(usize, usize)> = candidates
|
||||
.iter()
|
||||
.filter_map(|&i| sheet.rules.get(i).map(|r| (i, r)))
|
||||
.filter(|(_, r)| r.tag.as_deref().is_none_or(|t| t == name) && r.classes.iter().all(|c| classes.contains(&c.as_str())))
|
||||
.map(|(i, r)| (r.specificity, i))
|
||||
.collect();
|
||||
hits.sort_unstable();
|
||||
let mut css = String::new();
|
||||
let mut count = 0;
|
||||
for (_, i) in hits {
|
||||
let Some(r) = sheet.rules.get(i) else { continue };
|
||||
if css.len() + r.decls.len() > MAX_RULE_CSS || count + r.count > MAX_DECLS - INLINE_DECLS {
|
||||
break;
|
||||
}
|
||||
css.push_str(&r.decls);
|
||||
css.push(';');
|
||||
count += r.count;
|
||||
}
|
||||
// A page whose rules cost too much to apply reads on without them.
|
||||
self.css_work = self.css_work.saturating_sub(candidates.len() + css.len());
|
||||
css.push_str(&inline);
|
||||
important_last(&css)
|
||||
}
|
||||
}
|
||||
|
||||
/// `css`'s declarations in order, the `!important` ones after the others: they win over normal
|
||||
/// ones wherever they come from (CSS Cascade 5 §6.4), an inline one over the sheet's.
|
||||
fn important_last(css: &str) -> String {
|
||||
let (important, normal): (Vec<&str>, Vec<&str>) = css.split(';').filter(|d| !d.trim().is_empty()).partition(|d| strip_important(d) != d.trim());
|
||||
normal.into_iter().chain(important).collect::<Vec<_>>().join(";")
|
||||
}
|
||||
|
||||
/// The borders a cell's CSS gives it (`border`, `border-top`…: style, width and colour in any
|
||||
/// order; `windowtext` is the automatic colour). `None` when it states none.
|
||||
fn cell_borders(css: &str) -> Option<wordcraft_doc::props::Borders> {
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders};
|
||||
let mut b = Borders::default();
|
||||
let mut any = false;
|
||||
for (k, v) in decls(css) {
|
||||
let sides: &[u8] = match k.as_str() {
|
||||
"border" => b"tlbr",
|
||||
"border-top" => b"t",
|
||||
"border-left" => b"l",
|
||||
"border-bottom" => b"b",
|
||||
"border-right" => b"r",
|
||||
_ => continue,
|
||||
};
|
||||
// CSS's default width is `medium`.
|
||||
let (mut style, mut width, mut color) = (None, 2.25_f32, None);
|
||||
for tok in v.split_whitespace() {
|
||||
let lt = tok.to_ascii_lowercase();
|
||||
match lt.as_str() {
|
||||
"none" | "hidden" => style = Some(BorderStyle::None),
|
||||
"solid" => style = Some(BorderStyle::Single),
|
||||
"double" => style = Some(BorderStyle::Double),
|
||||
"dotted" => style = Some(BorderStyle::Dotted),
|
||||
"dashed" => style = Some(BorderStyle::Dashed),
|
||||
"thin" => width = 0.75,
|
||||
"medium" => width = 2.25,
|
||||
"thick" => width = 3.75,
|
||||
"windowtext" => {}
|
||||
_ => match css_len_pt(tok).filter(|_| tok.starts_with(|c: char| c.is_ascii_digit() || c == '.')) {
|
||||
Some(w) => width = w.clamp(0.0, 12.0),
|
||||
None => color = parse_color(tok).or(color),
|
||||
},
|
||||
}
|
||||
}
|
||||
// CSS draws no border without a style, or at no width.
|
||||
let style = if width > 0.0 { style.unwrap_or(BorderStyle::None) } else { BorderStyle::None };
|
||||
let border = Border { style, width, color, space: 0.0 };
|
||||
for side in sides {
|
||||
let slot = match side {
|
||||
b't' => &mut b.top,
|
||||
b'l' => &mut b.left,
|
||||
b'b' => &mut b.bottom,
|
||||
_ => &mut b.right,
|
||||
};
|
||||
*slot = Some(border);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
any.then_some(b)
|
||||
}
|
||||
|
||||
/// Most columns given widths in one table.
|
||||
const MAX_TABLE_COLS: usize = 63;
|
||||
|
||||
/// A cell's width, points: its CSS `width`, else its `width` attribute (pixels); percentages
|
||||
/// and widths that don't parse give none.
|
||||
fn cell_width(css: &str, attrs: &[(String, String)]) -> Option<f32> {
|
||||
let from_css = decls(css).filter(|(k, v)| k == "width" && !v.ends_with('%')).filter_map(|(_, v)| css_len_pt(v));
|
||||
let from_attr = || {
|
||||
attr(attrs, "width")
|
||||
.map(str::trim)
|
||||
.filter(|v| !v.ends_with('%'))
|
||||
.and_then(|v| v.trim_end_matches("px").trim().parse::<f32>().ok())
|
||||
.map(|px| px * 0.75)
|
||||
};
|
||||
from_css.last().or_else(from_attr).filter(|w| w.is_finite() && *w > 1.0 && *w <= 1584.0)
|
||||
}
|
||||
|
||||
fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
impl<'r> Builder<'r> {
|
||||
pub(super) fn new(images: ImageLoader<'r>) -> Builder<'r> {
|
||||
pub(super) fn new(images: ImageLoader<'r>, page: bool) -> Builder<'r> {
|
||||
Builder {
|
||||
images,
|
||||
containers: vec![Vec::new()],
|
||||
@@ -215,11 +545,19 @@ impl<'r> Builder<'r> {
|
||||
pre_tail: false,
|
||||
meta: Meta::default(),
|
||||
title: None,
|
||||
sheet: Sheet::default(),
|
||||
page,
|
||||
css_work: MAX_CSS_WORK,
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt(&self) -> Fmt {
|
||||
self.stack.last().map(|e| e.fmt.clone()).unwrap_or_default()
|
||||
match self.stack.last() {
|
||||
Some(e) => e.fmt.clone(),
|
||||
// A web page's text is Times New Roman 12 pt unless its CSS says otherwise.
|
||||
None if self.page => Fmt { font: Some(PAGE_FONT.into()), size: Some(12.0), ..Fmt::default() },
|
||||
None => Fmt::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn preserve(&self) -> bool {
|
||||
@@ -237,6 +575,15 @@ impl<'r> Builder<'r> {
|
||||
let mut list_level: Option<(bool, usize)> = None;
|
||||
let mut li_at: Option<usize> = None;
|
||||
let mut lists = 0usize;
|
||||
let mut spaced = false;
|
||||
// A page's first span stating margins (see below).
|
||||
let span = self
|
||||
.stack
|
||||
.iter()
|
||||
.rev()
|
||||
.take_while(|e| e.kind == ElKind::Inline)
|
||||
.find(|e| e.spacing.before.is_some() || e.spacing.after.is_some())
|
||||
.map(|e| (e.spacing.before, e.spacing.after));
|
||||
for (i, e) in self.stack.iter().enumerate().rev() {
|
||||
match e.kind {
|
||||
ElKind::Cell => {
|
||||
@@ -270,6 +617,27 @@ impl<'r> Builder<'r> {
|
||||
if p.align.is_none() && e.align.is_some() && e.kind != ElKind::Inline {
|
||||
p.align = e.align;
|
||||
}
|
||||
// Margins belong to the paragraph's own block, not to those around it.
|
||||
if !spaced && e.kind != ElKind::Inline {
|
||||
p.spacing = e.spacing;
|
||||
if self.page {
|
||||
// Word opens a page's paragraphs with single lines; a margin a span
|
||||
// states replaces the one its paragraph states, and one the paragraph
|
||||
// leaves out is automatic spacing.
|
||||
let (top, bottom) = span.unwrap_or_default();
|
||||
let sp = &mut p.spacing;
|
||||
sp.before_auto = sp.before.is_none() && e.auto_margins;
|
||||
sp.after_auto = sp.after.is_none() && e.auto_margins;
|
||||
sp.before = sp.before.map(|own| top.unwrap_or(own));
|
||||
sp.after = sp.after.map(|own| bottom.unwrap_or(own));
|
||||
sp.line = sp.line.or(Some(LineSpacing::Multiple(1.0)));
|
||||
}
|
||||
spaced = true;
|
||||
}
|
||||
}
|
||||
// Page text without a block of its own (straight in a cell) is single-spaced too.
|
||||
if self.page && !spaced {
|
||||
p.spacing.line = Some(LineSpacing::Multiple(1.0));
|
||||
}
|
||||
if let (Some((o, _)), Some(li)) = (list_level, li_at) {
|
||||
p.list = Some(ListInfo { ordered: o, level: lists.saturating_sub(1).min(8) as u8, start: 1 });
|
||||
@@ -310,7 +678,11 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
}
|
||||
if p.inlines.len() == 1 && p.text() == "\u{00A0}" {
|
||||
// `<p> </p>`: an empty paragraph on purpose.
|
||||
// `<p> </p>`: an empty paragraph on purpose, whose line is as high as the
|
||||
// space's formatting makes it.
|
||||
if let Some(Inline::Text(_, f)) = p.inlines.first() {
|
||||
p.mark = Some(Box::new(f.clone()));
|
||||
}
|
||||
p.inlines.clear();
|
||||
self.para_cont = false;
|
||||
self.container().push(FBlock::Para(p));
|
||||
@@ -482,6 +854,10 @@ impl<'r> Builder<'r> {
|
||||
let mut ft = FTable { rows, widths: Vec::new(), borderless };
|
||||
if !ft.rows.is_empty() {
|
||||
ft.insert_covered();
|
||||
// Columns keep the widths their cells give when every column has one.
|
||||
if ft.cols() <= MAX_TABLE_COLS {
|
||||
ft.widths_from_cells();
|
||||
}
|
||||
self.container().push(FBlock::Table(ft));
|
||||
}
|
||||
}
|
||||
@@ -625,13 +1001,15 @@ impl<'r> Builder<'r> {
|
||||
true
|
||||
}
|
||||
|
||||
fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind) -> ElementStyle {
|
||||
/// `css`: the element's declarations in cascade order ([`Builder::css_of`]).
|
||||
fn element_style(&self, name: &str, attrs: &[(String, String)], kind: ElKind, css: &str) -> ElementStyle {
|
||||
let mut fmt = self.fmt();
|
||||
let mut align = None;
|
||||
let mut preserve = self.preserve();
|
||||
let mut page_break = false;
|
||||
let mut page_break_after = false;
|
||||
match name {
|
||||
// A header cell is bold, as in browsers and Word, unless its own CSS says otherwise.
|
||||
"b" | "strong" | "th" => fmt.bold = true,
|
||||
"i" | "em" | "cite" | "var" | "dfn" | "address" => fmt.italic = true,
|
||||
"u" | "ins" => fmt.underline = true,
|
||||
@@ -662,9 +1040,16 @@ impl<'r> Builder<'r> {
|
||||
if name == "center" {
|
||||
align = Some(Align::Center);
|
||||
}
|
||||
if let Some(style) = attr(attrs, "style") {
|
||||
apply_style(style, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
|
||||
if self.page
|
||||
&& let ElKind::Heading(n) = kind
|
||||
{
|
||||
// A page's headings are bold, black (unless they inherit a colour) and sized as
|
||||
// browsers show them.
|
||||
fmt.bold = true;
|
||||
fmt.color.get_or_insert(wordcraft_doc::Rgb(0, 0, 0));
|
||||
fmt.size = PAGE_HEADING_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied();
|
||||
}
|
||||
apply_style(css, &mut fmt, &mut align, &mut preserve, &mut page_break, &mut page_break_after);
|
||||
if kind == ElKind::Pre {
|
||||
preserve = true;
|
||||
}
|
||||
@@ -703,9 +1088,10 @@ impl<'r> Builder<'r> {
|
||||
if kind != ElKind::Inline {
|
||||
self.flush();
|
||||
}
|
||||
let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind);
|
||||
let css = self.css_of(name, attrs);
|
||||
let ElementStyle { mut fmt, align, preserve, page_break, page_break_after } = self.element_style(name, attrs, kind, &css);
|
||||
match kind {
|
||||
ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, true) }),
|
||||
ElKind::Table => self.tables.push(TableB { rows: Vec::new(), row: None, bordered: declares_border(attrs, &css, true) }),
|
||||
ElKind::Tr => {
|
||||
if let Some(t) = self.tables.last_mut()
|
||||
&& let Some(r) = t.row.take()
|
||||
@@ -716,7 +1102,7 @@ impl<'r> Builder<'r> {
|
||||
ElKind::Cell => {
|
||||
let tb = self.find_open(|e| e.kind == ElKind::Tr, |e| e.kind == ElKind::Table).is_none();
|
||||
if let Some(t) = self.tables.last_mut()
|
||||
&& declares_border(attrs, false)
|
||||
&& declares_border(attrs, &css, false)
|
||||
{
|
||||
t.bordered = true;
|
||||
}
|
||||
@@ -728,6 +1114,7 @@ impl<'r> Builder<'r> {
|
||||
t.row = Some(Vec::new());
|
||||
}
|
||||
let num = |k: &str| attr(attrs, k).and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 63));
|
||||
let colspan = num("colspan").unwrap_or(1);
|
||||
let shading = attr(attrs, "bgcolor").and_then(parse_color).or(fmt.background);
|
||||
let mut valign = attr(attrs, "valign").and_then(|v| match v.trim().to_ascii_lowercase().as_str() {
|
||||
"top" => Some(VAlign::Top),
|
||||
@@ -735,36 +1122,35 @@ impl<'r> Builder<'r> {
|
||||
"bottom" => Some(VAlign::Bottom),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(st) = attr(attrs, "style") {
|
||||
for decl in st.split(';') {
|
||||
if let Some((k, v)) = decl.split_once(':')
|
||||
&& k.trim().eq_ignore_ascii_case("vertical-align")
|
||||
{
|
||||
match v.trim().to_ascii_lowercase().as_str() {
|
||||
"top" => valign = Some(VAlign::Top),
|
||||
"middle" | "center" => valign = Some(VAlign::Center),
|
||||
"bottom" => valign = Some(VAlign::Bottom),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for (_, v) in decls(&css).filter(|(k, _)| k == "vertical-align") {
|
||||
match v.to_ascii_lowercase().as_str() {
|
||||
"top" => valign = Some(VAlign::Top),
|
||||
"middle" | "center" => valign = Some(VAlign::Center),
|
||||
"bottom" => valign = Some(VAlign::Bottom),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.cells.push(Cell {
|
||||
blocks: Vec::new(),
|
||||
colspan: num("colspan").unwrap_or(1),
|
||||
colspan,
|
||||
rowspan: attr(attrs, "rowspan").and_then(|v| v.trim().parse::<u32>().ok()).map(|v| v.clamp(1, 1000)).unwrap_or(1),
|
||||
covered: false,
|
||||
header: name == "th",
|
||||
shading,
|
||||
borders: None,
|
||||
borders: cell_borders(&css),
|
||||
valign,
|
||||
width: if colspan == 1 { cell_width(&css, attrs) } else { None },
|
||||
});
|
||||
fmt.background = None;
|
||||
self.containers.push(Vec::new());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.stack.push(El { name: name.to_string(), kind, fmt, align, preserve, page_break, page_break_after, used: false });
|
||||
// The page's own margins (`body`, `html`) aren't its paragraphs'.
|
||||
let spacing =
|
||||
if (kind != ElKind::Inline || self.page) && !matches!(name, "body" | "html") { block_spacing(&css) } else { Spacing::default() };
|
||||
let auto_margins = self.page && (name == "p" || matches!(kind, ElKind::Heading(_)));
|
||||
self.stack.push(El { name: name.to_string(), kind, fmt, spacing, auto_margins, align, preserve, page_break, page_break_after, used: false });
|
||||
}
|
||||
|
||||
pub(super) fn end(&mut self, name: &str) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::builder::decls;
|
||||
use super::import::PAGE_FONT;
|
||||
use super::{Align, Fmt, model, parse_color};
|
||||
|
||||
/// A CSS length (or font-size keyword) in points; `None` when it is not one.
|
||||
@@ -33,7 +35,7 @@ pub fn css_len_pt(v: &str) -> Option<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_important(v: &str) -> &str {
|
||||
pub(super) fn strip_important(v: &str) -> &str {
|
||||
let s = v.trim();
|
||||
if let Some(pos) = s.rfind('!') {
|
||||
let after = s[pos + 1..].trim();
|
||||
@@ -53,10 +55,7 @@ pub(super) fn apply_style(
|
||||
page_break: &mut bool,
|
||||
page_break_after: &mut bool,
|
||||
) {
|
||||
for decl in css.split(';').take(64) {
|
||||
let Some((k, v)) = decl.split_once(':') else { continue };
|
||||
let k = k.trim().to_ascii_lowercase();
|
||||
let v = strip_important(v);
|
||||
for (k, v) in decls(css) {
|
||||
let lv = v.to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
"font-weight" => f.bold = lv.starts_with("bold") || lv.parse::<u32>().is_ok_and(|w| w >= 600),
|
||||
@@ -94,6 +93,14 @@ pub(super) fn apply_style(
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
f.font = model::kept_font(&fam).or(f.font.take());
|
||||
} else if f.font.is_some() {
|
||||
// A generic family replaces a face the text has (a page's Times New Roman);
|
||||
// text without one keeps the document's font.
|
||||
match fam.to_ascii_lowercase().as_str() {
|
||||
"serif" => f.font = Some(PAGE_FONT.into()),
|
||||
"sans-serif" => f.font = Some("Arial".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
"vertical-align" => {
|
||||
|
||||
@@ -274,17 +274,39 @@ fn sniff_charset(head: &str) -> Option<&str> {
|
||||
|
||||
/// Decode HTML bytes: BOM, then a `charset` in the first 2 KB, else UTF-8 with a legacy fallback.
|
||||
pub fn decode(b: &[u8]) -> String {
|
||||
decode_as(b, None)
|
||||
}
|
||||
|
||||
/// [`decode`] in the `charset` its container states (a MIME part's, a content type's), when it
|
||||
/// states one. Single-byte Windows code pages (`windows-1250`…, `iso-8859-1`, `us-ascii` as 1252)
|
||||
/// are decoded as such, other labels by their WHATWG decoders.
|
||||
pub fn decode_as(b: &[u8], charset: Option<&str>) -> String {
|
||||
if b.starts_with(&[0xFF, 0xFE]) || b.starts_with(&[0xFE, 0xFF]) || b.starts_with(&[0xEF, 0xBB, 0xBF]) {
|
||||
return crate::txt::decode(b);
|
||||
}
|
||||
let head = String::from_utf8_lossy(b.get(..b.len().min(2048)).unwrap_or(b)).to_ascii_lowercase();
|
||||
if let Some(label) = sniff_charset(&head)
|
||||
&& let Some(s) = crate::txt::decode_as(b, label)
|
||||
{
|
||||
return s;
|
||||
let named = charset.map(str::to_ascii_lowercase).or_else(|| sniff_charset(&head).map(str::to_string));
|
||||
let code_page = match named.as_deref().map(str::trim) {
|
||||
Some("iso-8859-1" | "latin1" | "us-ascii") => Some(1252),
|
||||
Some(cs) => cs
|
||||
.strip_prefix("windows-")
|
||||
.or_else(|| cs.strip_prefix("cp"))
|
||||
.and_then(|n| n.parse::<u16>().ok())
|
||||
.filter(|cp| wordcraft_doc::encoding::SINGLE_BYTE_CODE_PAGES.contains(cp)),
|
||||
None => None,
|
||||
};
|
||||
// Other labels (ISO 8859, KOI8, Shift_JIS, GBK, Big5, EUC-KR…) by their WHATWG decoders; a
|
||||
// UTF-16 one without a BOM means UTF-8, as in browsers.
|
||||
let other = named
|
||||
.as_deref()
|
||||
.and_then(|l| encoding_rs::Encoding::for_label(l.trim().as_bytes()))
|
||||
.filter(|e| ![encoding_rs::UTF_8, encoding_rs::UTF_16LE, encoding_rs::UTF_16BE].contains(e));
|
||||
match (code_page, other) {
|
||||
(Some(cp), _) => b.iter().map(|&c| wordcraft_doc::encoding::windows_byte(cp, c)).collect(),
|
||||
(None, Some(enc)) => enc.decode_without_bom_handling(b).0.into_owned(),
|
||||
(None, None) => match std::str::from_utf8(b) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => crate::txt::decode(b),
|
||||
},
|
||||
}
|
||||
if let Ok(s) = std::str::from_utf8(b) {
|
||||
return s.to_string();
|
||||
}
|
||||
crate::txt::decode(b)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::builder::{Builder, ImageLoader};
|
||||
use super::builder::{Builder, ImageLoader, parse_sheet};
|
||||
use super::lexer::{Lexer, Tok};
|
||||
use super::math_ruby::{read_math, read_ruby};
|
||||
use super::{Document, Flow, Inline, model};
|
||||
@@ -10,6 +10,10 @@ pub fn parse(s: &str) -> Flow {
|
||||
|
||||
/// [`parse`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
parse_in(s, images, false)
|
||||
}
|
||||
|
||||
pub(super) fn parse_in(s: &str, images: ImageLoader, page: bool) -> Flow {
|
||||
// HTML normalizes input newlines before tokenization, not after decoding character references.
|
||||
let normalized = if s.contains('\r') {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
@@ -23,8 +27,10 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(s)
|
||||
};
|
||||
let mut lx = Lexer { s: &normalized, pos: 0 };
|
||||
let mut b = Builder::new(images);
|
||||
// The lexer's offsets (a style element's text is cut out by them) are into the normalized text.
|
||||
let s: &str = &normalized;
|
||||
let mut lx = Lexer { s, pos: 0 };
|
||||
let mut b = Builder::new(images, page);
|
||||
let mut guard = 0usize;
|
||||
while let Some(t) = lx.next() {
|
||||
guard += 1;
|
||||
@@ -52,7 +58,11 @@ pub fn parse_with(s: &str, images: ImageLoader) -> Flow {
|
||||
continue;
|
||||
}
|
||||
if matches!(name.as_str(), "script" | "style" | "noscript" | "template" | "textarea") && !self_close {
|
||||
lx.skip_past_end_tag(&name);
|
||||
let from = lx.pos;
|
||||
let to = lx.skip_past_end_tag(&name);
|
||||
if name == "style" {
|
||||
parse_sheet(s.get(from..to).unwrap_or(""), &mut b.sheet);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
b.start(&name, &attrs, self_close);
|
||||
@@ -91,9 +101,25 @@ pub fn import_with(s: &str, images: ImageLoader) -> Document {
|
||||
model::to_doc(&parse_with(s, images))
|
||||
}
|
||||
|
||||
/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page
|
||||
/// A web page opened as a document, as Word opens one (a file, or a document's HTML
|
||||
/// `w:altChunk`): its text is Times New Roman 12 pt on single lines, headings bold and black at
|
||||
/// the sizes browsers give them, and a `p` or heading whose CSS leaves a margin out (or writes it
|
||||
/// invalidly) gets HTML's automatic spacing there; a margin a span states replaces the one its
|
||||
/// paragraph states (both as Word's import does). [`import`] reads a pasted fragment, which takes
|
||||
/// the document's own formatting instead (but its CSS still applies).
|
||||
pub fn import_page(s: &str, images: ImageLoader) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse_in(s, images, true))
|
||||
}
|
||||
|
||||
/// [`import_page`] onto `page` (its size and margins), which tables size to: HTML gives no page
|
||||
/// size, so the caller picks it (the region's paper, #1381).
|
||||
pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc_in(&parse_with(s, images), page.clone())
|
||||
model::to_doc_in(&parse_in(s, images, true), page.clone())
|
||||
}
|
||||
|
||||
/// The font of a web page's text when its CSS names none.
|
||||
pub const PAGE_FONT: &str = "Times New Roman";
|
||||
/// Heading sizes on a web page, `h1`–`h6`, points.
|
||||
pub(super) const PAGE_HEADING_SIZES: [f32; 6] = [24.0, 18.0, 13.5, 12.0, 10.0, 7.5];
|
||||
@@ -34,7 +34,9 @@ impl Lexer<'_> {
|
||||
}
|
||||
|
||||
/// Skip past the next matching end tag `</tag...>` (ASCII case-insensitive), or to the end.
|
||||
pub(super) fn skip_past_end_tag(&mut self, tag: &str) {
|
||||
/// Skip past the end tag of raw text element `tag`; returns where that end tag starts (the
|
||||
/// end of the input when there is none).
|
||||
pub(super) fn skip_past_end_tag(&mut self, tag: &str) -> usize {
|
||||
let prefix = format!("</{tag}");
|
||||
let p = prefix.as_bytes();
|
||||
while self.pos < self.s.len() {
|
||||
@@ -42,17 +44,19 @@ impl Lexer<'_> {
|
||||
let found = hay.windows(p.len().max(1)).position(|w| w.eq_ignore_ascii_case(p));
|
||||
let Some(k) = found else {
|
||||
self.pos = self.s.len();
|
||||
return;
|
||||
return self.pos;
|
||||
};
|
||||
let after_tag = k + p.len();
|
||||
let next_byte = hay.get(after_tag);
|
||||
if next_byte.is_none_or(|b| b.is_ascii_whitespace() || *b == b'>' || *b == b'/') {
|
||||
let at = self.pos + k;
|
||||
self.pos += after_tag;
|
||||
self.skip_past(">");
|
||||
return;
|
||||
return at;
|
||||
}
|
||||
self.pos += k + 2;
|
||||
}
|
||||
self.s.len()
|
||||
}
|
||||
|
||||
pub(super) fn next(&mut self) -> Option<Tok> {
|
||||
|
||||
@@ -7,6 +7,27 @@ fn numeric_entity_rejects_sign() {
|
||||
assert_eq!(unescape("aAAb"), "aAAb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn important_declarations_win_the_cascade() {
|
||||
let color = |html: &str| fmt_of(paras(&parse(html))[0]).color;
|
||||
let red = Some(wordcraft_doc::Rgb(255, 0, 0));
|
||||
let blue = Some(wordcraft_doc::Rgb(0, 0, 255));
|
||||
assert_eq!(color("<style>p { color: red }</style><p style='color: blue'>x</p>"), blue);
|
||||
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue'>x</p>"), red);
|
||||
assert_eq!(color("<style>p { color: red !important }</style><p style='color: blue !important'>x</p>"), blue);
|
||||
assert_eq!(color("<p style='color: red !important; color: blue'>x</p>"), red);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_declared_charset_decodes_the_page() {
|
||||
// ISO 8859-2 (Central European): 0xA9 Š, 0xB9 š; named by the caller or the page.
|
||||
assert_eq!(decode_as(&[0xA9, b' ', 0xB9], Some("iso-8859-2")), "Š š");
|
||||
assert!(decode_as(b"<meta charset=iso-8859-2><p>\xA9</p>", None).contains("<p>Š</p>"));
|
||||
assert_eq!(decode_as(&[0x8A], Some("windows-1252")), "Š");
|
||||
// A page that says UTF-16 without a BOM is read as UTF-8, as browsers do.
|
||||
assert_eq!(decode_as("é".as_bytes(), Some("utf-16")), "é");
|
||||
}
|
||||
|
||||
fn texts(f: &Flow) -> Vec<String> {
|
||||
f.blocks
|
||||
.iter()
|
||||
@@ -33,6 +54,9 @@ fn borderless_tables_get_no_grid_style() {
|
||||
assert_eq!(table_style("<table border=1><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<table style='border:1px solid #000'><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<table><tr><td style='border:1px solid #999'>a</table>").as_deref(), Some("TableGrid"));
|
||||
// The last declaration decides: a `style` attribute's over the style sheet's.
|
||||
assert_eq!(table_style("<style>table { border: 1px solid }</style><table><tr><td>a</table>").as_deref(), Some("TableGrid"));
|
||||
assert_eq!(table_style("<style>table { border: 1px solid }</style><table style='border:none'><tr><td>a</table>").as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -522,3 +546,171 @@ fn empty_comments_do_not_swallow_the_rest() {
|
||||
assert_eq!(doc.plain_text(Default::default()), "ab\nc", "{html}");
|
||||
}
|
||||
}
|
||||
|
||||
fn paras(f: &Flow) -> Vec<&Para> {
|
||||
f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||||
}
|
||||
fn fmt_of(p: &Para) -> Fmt {
|
||||
p.inlines.iter().find_map(|i| if let Inline::Text(_, f) = i { Some(f.clone()) } else { None }).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_sheet_rules_apply_in_cascade_order() {
|
||||
let f = parse(
|
||||
"<style><!-- p { color: #00ff00; font-size: 10pt } .big { font-size: 20pt } p.big { font-weight: bold } \
|
||||
/* a comment { } */ @media print { p { color: #ff0000 } } div p, li:hover { font-style: italic } .000003 { text-align: center } --></style>\
|
||||
<p>plain</p><p class='big x'>big</p><p class=big style='font-size: 8pt'>inline wins</p><p class=000003>digits</p>",
|
||||
);
|
||||
let ps = paras(&f);
|
||||
let (plain, big, inline, digits) = (fmt_of(ps[0]), fmt_of(ps[1]), fmt_of(ps[2]), fmt_of(ps[3]));
|
||||
assert_eq!((plain.color, plain.size, plain.bold, plain.italic), (Some(wordcraft_doc::Rgb(0, 255, 0)), Some(10.0), false, false));
|
||||
assert_eq!((big.size, big.bold), (Some(20.0), true));
|
||||
assert_eq!(inline.size, Some(8.0));
|
||||
assert_eq!(ps[3].align, Some(Align::Center));
|
||||
assert_eq!(digits.size, Some(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_css_states_spacing_indents_and_line_height() {
|
||||
let f = parse(
|
||||
"<style>p.a { margin: 6pt 1in 3pt; text-indent: -18pt; line-height: 150% }</style>\
|
||||
<p class=a>a</p><p style='margin-top: 14,4px; margin-bottom: auto; line-height: 20pt'>b</p><p>c</p>",
|
||||
);
|
||||
let ps = paras(&f);
|
||||
assert_eq!(
|
||||
ps[0].spacing,
|
||||
Spacing {
|
||||
before: Some(6.0),
|
||||
after: Some(3.0),
|
||||
left: Some(72.0),
|
||||
right: Some(72.0),
|
||||
first: Some(-18.0),
|
||||
line: Some(LineSpacing::Multiple(1.5)),
|
||||
..Spacing::default()
|
||||
}
|
||||
);
|
||||
// Values that don't parse (a comma decimal, `auto`) state nothing.
|
||||
assert_eq!(ps[1].spacing, Spacing { line: Some(LineSpacing::Exactly(20.0)), ..Spacing::default() });
|
||||
assert_eq!(ps[2].spacing, Spacing::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_opens_as_word_opens_one() {
|
||||
let html = "<style>p.k { margin-bottom: 13px } span.z { margin: 0in }</style>\
|
||||
<p>first</p><h1>Head</h1><p class=k><span class=z>kept</span></p><p class=k>own</p><div>bare</div><p> </p>";
|
||||
let f = parse_in(html, &|_| None, true);
|
||||
let ps = paras(&f);
|
||||
// Times New Roman 12 pt on single lines; headings bold and black at a browser's sizes.
|
||||
let first = fmt_of(ps[0]);
|
||||
assert_eq!((first.font.as_deref(), first.size), (Some(PAGE_FONT), Some(12.0)));
|
||||
assert_eq!(ps[0].spacing.line, Some(LineSpacing::Multiple(1.0)));
|
||||
let cell = parse_in("<table><tr><td>cell</td></tr></table>", &|_| None, true);
|
||||
let FBlock::Table(t) = &cell.blocks[0] else { panic!() };
|
||||
let FBlock::Para(cp) = &t.rows[0][0].blocks[0] else { panic!() };
|
||||
assert_eq!(cp.spacing.line, Some(LineSpacing::Multiple(1.0)));
|
||||
let head = fmt_of(ps[1]);
|
||||
assert_eq!((head.bold, head.size, head.color), (true, Some(24.0), Some(wordcraft_doc::Rgb(0, 0, 0))));
|
||||
// A colour the heading inherits stays.
|
||||
// A generic family names a face on a page; pasted text keeps the document's font.
|
||||
let generic = parse_in("<p style='font-family: sans-serif'>a</p><p style='font-family: serif'>b</p>", &|_| None, true);
|
||||
let fonts: Vec<Option<String>> = paras(&generic).iter().map(|p| fmt_of(p).font.map(|f| f.to_string())).collect();
|
||||
assert_eq!(fonts, [Some("Arial".to_string()), Some(PAGE_FONT.to_string())]);
|
||||
assert_eq!(fmt_of(paras(&parse("<p style='font-family: sans-serif'>a</p>"))[0]).font, None);
|
||||
let inherited = parse_in("<body style='color: #000080'><h1>Head</h1></body>", &|_| None, true);
|
||||
assert_eq!(fmt_of(paras(&inherited)[0]).color, Some(wordcraft_doc::Rgb(0, 0, 0x80)));
|
||||
// Automatic spacing where a p or heading leaves its margins out (layout drops it at the
|
||||
// top of the page and of a cell).
|
||||
let auto = |p: &Para| (p.spacing.before_auto, p.spacing.after_auto, p.spacing.before, p.spacing.after);
|
||||
assert_eq!(auto(ps[0]), (true, true, None, None));
|
||||
assert_eq!(auto(ps[1]), (true, true, None, None));
|
||||
// A span's margin replaces the one its paragraph states; the one it leaves out stays automatic.
|
||||
assert_eq!(auto(ps[2]), (true, false, None, Some(0.0)));
|
||||
assert_eq!(auto(ps[3]), (true, false, None, Some(9.75)));
|
||||
// Other blocks get no automatic spacing.
|
||||
assert_eq!(auto(ps[4]), (false, false, None, None));
|
||||
// The document says so, and the empty paragraph's mark keeps its size.
|
||||
let d = model::to_doc(&f);
|
||||
let props = |i: usize| match &*d.body[i] {
|
||||
wordcraft_doc::Block::Para(p) => p.clone(),
|
||||
b => panic!("{b:?}"),
|
||||
};
|
||||
assert_eq!((props(0).props.space_before_auto, props(0).props.space_after_auto), (Some(true), Some(true)));
|
||||
assert_eq!((props(2).props.space_after_auto, props(2).props.space_after), (None, Some(0.0)));
|
||||
assert_eq!(props(5).mark.size, Some(12.0));
|
||||
// `<p> </p>` keeps the size of its space for its line.
|
||||
assert!(ps[5].inlines.is_empty());
|
||||
assert_eq!(ps[5].mark.as_ref().and_then(|m| m.size), Some(12.0));
|
||||
// A fragment (a paste) keeps the document's own formatting.
|
||||
let pasted = parse(html);
|
||||
assert_eq!(fmt_of(paras(&pasted)[0]).font, None);
|
||||
assert_eq!(paras(&pasted)[0].spacing, Spacing::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_give_their_columns_widths() {
|
||||
let f = parse(
|
||||
"<style>td.n { width: 42px }</style><table border=1><tr><td colspan=3>t</td></tr><tr><td class=n>1</td><td width=200>a</td><td style='width: 2in'>b</td></tr></table>",
|
||||
);
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!("{:?}", f.blocks) };
|
||||
assert_eq!(t.widths, [31.5, 150.0, 144.0]);
|
||||
// A column without a width leaves them all to the layout.
|
||||
let f = parse("<table><tr><td width=100>a</td><td>b</td></tr></table>");
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert!(t.widths.is_empty());
|
||||
// A cell beside a cell spanning rows from above gives its own column its width.
|
||||
let f =
|
||||
parse("<table><tr><td rowspan=2>a</td><td width=200>b</td></tr><tr><td width=300>c</td></tr><tr><td width=100>d</td><td>e</td></tr></table>");
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
assert_eq!(t.widths, [75.0, 150.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_take_their_css_borders() {
|
||||
use wordcraft_doc::props::{Border, BorderStyle};
|
||||
let f = parse(
|
||||
"<style>td.b { border-top: solid windowtext 1px; border-bottom: 2pt double #ff0000; border-left: none; border-right: 0px solid; vertical-align: bottom }</style><table><tr><td class=b>a</td><td>b</td></tr></table>",
|
||||
);
|
||||
let FBlock::Table(t) = &f.blocks[0] else { panic!() };
|
||||
// The style sheet's other cell properties apply too.
|
||||
assert_eq!(t.rows[0][0].valign, Some(wordcraft_doc::props::VAlign::Bottom));
|
||||
let b = t.rows[0][0].borders.expect("borders");
|
||||
assert_eq!(b.top, Some(Border::single(0.75)));
|
||||
assert_eq!(b.bottom, Some(Border { style: BorderStyle::Double, width: 2.0, color: Some(wordcraft_doc::Rgb(255, 0, 0)), space: 0.0 }));
|
||||
assert_eq!(b.left.map(|l| l.style), Some(BorderStyle::None));
|
||||
// A border of no width is none (OOXML has no zero-width line).
|
||||
assert_eq!(b.right.map(|r| r.style), Some(BorderStyle::None));
|
||||
assert_eq!(t.rows[0][1].borders, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_sheet_edge_cases() {
|
||||
let f = parse_in(
|
||||
"<style>@charset \"utf-8\"; @import url(a.css); p.a { color: #0000ff } body { margin: 20px } p.b { margin: 10 5% medium }</style>\
|
||||
<body><p class=a>a</p>bare<p class=b>b</p></body>",
|
||||
&|_| None,
|
||||
true,
|
||||
);
|
||||
let ps = paras(&f);
|
||||
// A rule right after statement at-rules still applies.
|
||||
assert_eq!(fmt_of(ps[0]).color, Some(wordcraft_doc::Rgb(0, 0, 255)));
|
||||
// The body's margins aren't the paragraphs' indents.
|
||||
assert_eq!((ps[1].spacing.left, ps[1].spacing.before), (None, None));
|
||||
// Bare numbers, percentages and keywords state no margin: automatic spacing stays.
|
||||
assert_eq!((ps[2].spacing.before_auto, ps[2].spacing.left), (true, None));
|
||||
// The `style` attribute wins even over a sheet with more declarations than are read.
|
||||
let many = (0..600).map(|i| format!("x-{i}: 1")).collect::<Vec<_>>().join(";");
|
||||
let f = parse(&format!("<style>p {{ {many}; color: #ff0000 }}</style><p style='color: #00ff00'>c</p>"));
|
||||
assert_eq!(fmt_of(paras(&f)[0]).color, Some(wordcraft_doc::Rgb(0, 255, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_style_sheets_never_panic() {
|
||||
// One declaration block shared by many selectors isn't copied for each.
|
||||
let selectors = vec!["p"; 20_000].join(",");
|
||||
let huge = format!("<style>{selectors} {{ color: red; {} }}</style><p>x</p>", "x: y;".repeat(200_000));
|
||||
let _ = parse_in(&huge, &|_| None, true);
|
||||
let many = ".a { color: red } ".repeat(20_000);
|
||||
for css in ["{", "}", "p {", "@media { p { x } ", "p { color: red", "/* open", ".{}", "p..a{}", "\u{0}.é{x:y}", many.as_str()] {
|
||||
let _ = parse_in(&format!("<style>{css}</style><p class='a é'>x</p><td style='width: 1e40px'>y</td>"), &|_| None, true);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! WordCraft import/export for the "other" text formats: plain text, Markdown (CommonMark subset
|
||||
//! plus GFM tables and strikethrough), HTML, RTF, OpenDocument Text and LaTeX — and, read-only,
|
||||
//! spreadsheet cells for mail-merge recipient lists ([`sheet`]: .xlsx/.xlsm/.xls/.ods).
|
||||
//! plus GFM tables and strikethrough), HTML (and MHT web archives, read-only), RTF, OpenDocument
|
||||
//! Text and LaTeX — and, read-only, spreadsheet cells for mail-merge recipient lists ([`sheet`]:
|
||||
//! .xlsx/.xlsm/.xls/.ods).
|
||||
//!
|
||||
//! Every format maps to a small flow model ([`model::Flow`]: paragraphs with a kind, list
|
||||
//! membership, alignment and formatted spans; tables of cells), which [`model::to_doc`] and
|
||||
@@ -14,6 +15,7 @@ pub mod html;
|
||||
pub mod latex;
|
||||
pub mod markdown;
|
||||
pub mod mathml;
|
||||
pub mod mht;
|
||||
pub mod model;
|
||||
pub mod odt;
|
||||
pub mod rtf;
|
||||
@@ -23,7 +25,7 @@ pub mod txt;
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
/// Extensions this crate handles.
|
||||
pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "rtf", "odt", "tex", "latex", "ltx"];
|
||||
pub const EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "html", "htm", "xhtml", "mht", "mhtml", "rtf", "odt", "tex", "latex", "ltx"];
|
||||
|
||||
/// Import `bytes` in the format named by `ext` (no dot, any case). `None` = not our format.
|
||||
pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
@@ -31,7 +33,8 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
let r = match ext.as_str() {
|
||||
"txt" | "text" => Ok(txt::import(bytes)),
|
||||
"md" | "markdown" => Ok(markdown::import(&txt::decode(bytes))),
|
||||
"html" | "htm" | "xhtml" => Ok(html::import(&html::decode(bytes))),
|
||||
"html" | "htm" | "xhtml" => Ok(html::import_page(&html::decode(bytes), &|_| None)),
|
||||
"mht" | "mhtml" => Ok(mht::import(bytes)),
|
||||
"rtf" => rtf::import(bytes),
|
||||
"odt" => odt::import(bytes),
|
||||
"tex" | "latex" | "ltx" => Ok(latex::import(bytes)),
|
||||
@@ -44,7 +47,7 @@ pub fn import(ext: &str, bytes: &[u8]) -> Option<Result<Document, String>> {
|
||||
}
|
||||
|
||||
/// [`import`] onto `page` (its size and margins) for the formats whose files give no page size:
|
||||
/// plain text, Markdown and HTML start on it, and their tables size to it (#1381). The other
|
||||
/// plain text, Markdown, HTML and MHT start on it, and their tables size to it (#1381). The other
|
||||
/// formats keep the page their file gives.
|
||||
pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Option<Result<Document, String>> {
|
||||
let ext = ext.trim_start_matches('.').to_ascii_lowercase();
|
||||
@@ -52,6 +55,7 @@ pub fn import_on(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) ->
|
||||
"txt" | "text" => txt::import_on(bytes, page),
|
||||
"md" | "markdown" => markdown::import_on(&txt::decode(bytes), page),
|
||||
"html" | "htm" | "xhtml" => html::import_on(&html::decode(bytes), &|_| None, page),
|
||||
"mht" | "mhtml" => mht::import_on(bytes, page),
|
||||
_ => return import(&ext, bytes),
|
||||
};
|
||||
doc.ensure_nonempty();
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
//! MHT / MHTML (Single File Web Page): a web page and its pictures in one MIME message (RFC 2557
|
||||
//! `multipart/related`, RFC 2045 transfer encodings). The page is the part the message's `start`
|
||||
//! parameter names, else its first HTML part; it is read by [`crate::html`], with `img` sources
|
||||
//! found among the other parts by `Content-Location` (also relative to the page's) or `cid:`
|
||||
//! `Content-ID`, and the page opens as Word opens one ([`crate::html::import_page`]). Word keeps
|
||||
//! HTML `w:altChunk`s in this form too.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
/// Most parts read from one message (nested multiparts included).
|
||||
const MAX_PARTS: usize = 10_000;
|
||||
/// Deepest `multipart` nesting followed.
|
||||
const MAX_NESTING: usize = 8;
|
||||
/// Longest header block looked at for one part.
|
||||
const MAX_HEADER: usize = 64 * 1024;
|
||||
/// Longest boundary (RFC 2046 §5.1.1).
|
||||
const MAX_BOUNDARY: usize = 70;
|
||||
/// Picture bytes one page may load, counting every `img` that names a part.
|
||||
const PICTURE_BUDGET: usize = 1 << 30;
|
||||
|
||||
/// One leaf part of the message: its headers (names lower-cased) and decoded body.
|
||||
struct Part {
|
||||
content_type: String,
|
||||
location: Option<String>,
|
||||
id: Option<String>,
|
||||
/// Shared by every `img` that names it.
|
||||
body: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Part {
|
||||
fn mime(&self) -> String {
|
||||
self.content_type.split(';').next().unwrap_or("").trim().to_ascii_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `bytes` look like a MIME message (an `.mht` file)?
|
||||
pub fn is_mht(bytes: &[u8]) -> bool {
|
||||
let head = String::from_utf8_lossy(bytes.get(..bytes.len().min(4096)).unwrap_or(bytes)).to_ascii_lowercase();
|
||||
head.starts_with("mime-version:") || head.starts_with("content-type: multipart/") || head.contains("\ncontent-type: multipart/related")
|
||||
}
|
||||
|
||||
/// Import an MHT file. A message without an HTML part reads as its text (an empty document
|
||||
/// when there's none).
|
||||
pub fn import(bytes: &[u8]) -> Document {
|
||||
import_on(bytes, &wordcraft_doc::SectionProps::default())
|
||||
}
|
||||
|
||||
/// [`import`] onto `page`: like HTML, an MHT file gives no page size (#1381).
|
||||
pub fn import_on(bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Document {
|
||||
let mut parts = Vec::new();
|
||||
let (headers, body) = split_headers(bytes);
|
||||
let start = param(header(&headers, "content-type").unwrap_or(""), "start").map(|s| s.trim_matches(['<', '>']).to_string());
|
||||
collect(&headers, body, &mut parts, 0);
|
||||
let root = start
|
||||
.as_deref()
|
||||
.and_then(|s| parts.iter().position(|p| p.id.as_deref() == Some(s)))
|
||||
.or_else(|| parts.iter().position(|p| matches!(p.mime().as_str(), "text/html" | "application/xhtml+xml")));
|
||||
let Some(root) = root.and_then(|i| parts.get(i)) else {
|
||||
let text = parts.iter().find(|p| p.mime() == "text/plain").map(|p| p.body.as_slice()).unwrap_or(&[]);
|
||||
return crate::txt::import_on(text, page);
|
||||
};
|
||||
let html = crate::html::decode_as(&root.body, param(&root.content_type, "charset"));
|
||||
let base = root.location.as_deref().unwrap_or("");
|
||||
// Parts by `Content-Location` (lower case) and `Content-ID`; the first of each wins.
|
||||
let mut by_location = HashMap::new();
|
||||
let mut by_id = HashMap::new();
|
||||
for p in &parts {
|
||||
if let Some(l) = &p.location {
|
||||
by_location.entry(l.to_ascii_lowercase()).or_insert(p);
|
||||
}
|
||||
if let Some(i) = &p.id {
|
||||
by_id.entry(i.as_str()).or_insert(p);
|
||||
}
|
||||
}
|
||||
let left = std::cell::Cell::new(PICTURE_BUDGET);
|
||||
let loader = |src: &str| -> Option<Arc<Vec<u8>>> {
|
||||
let src = src.trim();
|
||||
let part = match src.get(..4).filter(|s| s.eq_ignore_ascii_case("cid:")).and(src.get(4..)) {
|
||||
Some(cid) => by_id.get(cid),
|
||||
// By its location as written, or resolved against the page's.
|
||||
None => by_location.get(&src.to_ascii_lowercase()).or_else(|| by_location.get(&resolve(base, src).to_ascii_lowercase())),
|
||||
}?;
|
||||
left.set(left.get().checked_sub(part.body.len())?);
|
||||
Some(part.body.clone())
|
||||
};
|
||||
crate::html::import_on(&html, &loader, page)
|
||||
}
|
||||
|
||||
/// `reference` resolved against the page's location `base`, as RFC 2557 §5 asks (RFC 3986
|
||||
/// §5.2.2): a reference with a scheme stays as it is, `//host/…` takes the base's scheme, `/…`
|
||||
/// its scheme and host, an empty path its path and query, anything else is merged with its path
|
||||
/// (§5.2.3); `.` and `..` segments go (§5.2.4) and fragments are dropped. Nothing is fetched.
|
||||
fn resolve(base: &str, reference: &str) -> String {
|
||||
let (b, r) = (UriParts::of(base), UriParts::of(reference));
|
||||
let t = if r.scheme.is_some() {
|
||||
UriParts { path: remove_dots(&r.path), ..r }
|
||||
} else if r.authority.is_some() {
|
||||
UriParts { scheme: b.scheme, path: remove_dots(&r.path), ..r }
|
||||
} else if r.path.is_empty() {
|
||||
UriParts { query: r.query.or(b.query), ..b }
|
||||
} else if r.path.starts_with('/') {
|
||||
UriParts { path: remove_dots(&r.path), query: r.query, ..b }
|
||||
} else {
|
||||
let merged = if b.authority.is_some() && b.path.is_empty() {
|
||||
format!("/{}", r.path)
|
||||
} else {
|
||||
format!("{}{}", b.path.rfind('/').and_then(|i| b.path.get(..=i)).unwrap_or(""), r.path)
|
||||
};
|
||||
UriParts { path: remove_dots(&merged), query: r.query, ..b }
|
||||
};
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
/// A URI's parts (RFC 3986 §3): `scheme:`, `//authority`, path and `?query`; the fragment left out.
|
||||
struct UriParts<'a> {
|
||||
scheme: Option<&'a str>,
|
||||
authority: Option<&'a str>,
|
||||
path: String,
|
||||
query: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> UriParts<'a> {
|
||||
fn of(uri: &'a str) -> Self {
|
||||
let uri = uri.split('#').next().unwrap_or("");
|
||||
// A one-letter scheme is a Windows drive (`C:/…`), not a scheme.
|
||||
let scheme = uri.split_once(':').map(|(k, _)| k).filter(|k| {
|
||||
k.len() > 1 && k.starts_with(|c: char| c.is_ascii_alphabetic()) && k.chars().all(|c| c.is_ascii_alphanumeric() || "+-.".contains(c))
|
||||
});
|
||||
let rest = scheme.and_then(|k| uri.get(k.len() + 1..)).unwrap_or(uri);
|
||||
let (authority, rest) = match rest.strip_prefix("//") {
|
||||
Some(r) => {
|
||||
let end = r.find(['/', '?']).unwrap_or(r.len());
|
||||
(r.get(..end), r.get(end..).unwrap_or(""))
|
||||
}
|
||||
None => (None, rest),
|
||||
};
|
||||
let (path, query) = match rest.split_once('?') {
|
||||
Some((p, q)) => (p, Some(q)),
|
||||
None => (rest, None),
|
||||
};
|
||||
UriParts { scheme, authority, path: path.to_string(), query }
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UriParts<'_> {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if let Some(s) = self.scheme {
|
||||
write!(f, "{s}:")?;
|
||||
}
|
||||
if let Some(a) = self.authority {
|
||||
write!(f, "//{a}")?;
|
||||
}
|
||||
f.write_str(&self.path)?;
|
||||
if let Some(q) = self.query {
|
||||
write!(f, "?{q}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without its `.` and `..` segments (RFC 3986 §5.2.4); `..` stops at the root.
|
||||
fn remove_dots(path: &str) -> String {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
let segments: Vec<&str> = path.split('/').collect();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
match *seg {
|
||||
"." => {}
|
||||
".." => {
|
||||
if out.len() > 1 || out.first().is_some_and(|f| !f.is_empty()) {
|
||||
out.pop();
|
||||
}
|
||||
}
|
||||
s => out.push(s),
|
||||
}
|
||||
if i + 1 == segments.len() && matches!(*seg, "." | "..") {
|
||||
out.push("");
|
||||
}
|
||||
}
|
||||
out.join("/")
|
||||
}
|
||||
|
||||
/// Split a part into its unfolded header lines and its body (after the first empty line).
|
||||
fn split_headers(b: &[u8]) -> (Vec<(String, String)>, &[u8]) {
|
||||
// Whichever blank line comes first ends the headers.
|
||||
let blank = [find_bytes(b, b"\r\n\r\n").map(|i| (i, 4)), find_bytes(b, b"\n\n").map(|i| (i, 2))].into_iter().flatten().min();
|
||||
let (head, body) = match blank {
|
||||
Some((i, n)) => (b.get(..i).unwrap_or(&[]), b.get(i + n..).unwrap_or(&[])),
|
||||
// Headers only, or a body without them.
|
||||
None if b.first().is_some_and(|c| c.is_ascii_alphabetic()) && find_bytes(b, b":").is_some() => (b, &[][..]),
|
||||
None => (&[][..], b),
|
||||
};
|
||||
let head = String::from_utf8_lossy(head.get(..head.len().min(MAX_HEADER)).unwrap_or(head));
|
||||
let mut headers: Vec<(String, String)> = Vec::new();
|
||||
for line in head.lines() {
|
||||
if line.starts_with([' ', '\t']) {
|
||||
if let Some((_, v)) = headers.last_mut() {
|
||||
v.push(' ');
|
||||
v.push_str(line.trim());
|
||||
}
|
||||
} else if let Some((k, v)) = line.split_once(':') {
|
||||
headers.push((k.trim().to_ascii_lowercase(), v.trim().to_string()));
|
||||
}
|
||||
}
|
||||
(headers, body)
|
||||
}
|
||||
|
||||
fn header<'a>(h: &'a [(String, String)], name: &str) -> Option<&'a str> {
|
||||
h.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
/// A `; name=value` parameter of a header value (quotes removed), name case-insensitive.
|
||||
fn param<'a>(value: &'a str, name: &str) -> Option<&'a str> {
|
||||
value.split(';').skip(1).find_map(|p| {
|
||||
let (k, v) = p.split_once('=')?;
|
||||
k.trim().eq_ignore_ascii_case(name).then(|| v.trim().trim_matches('"'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Gather the leaf parts of a (possibly multipart) entity.
|
||||
fn collect(headers: &[(String, String)], body: &[u8], out: &mut Vec<Part>, depth: usize) {
|
||||
if out.len() >= MAX_PARTS {
|
||||
return;
|
||||
}
|
||||
let ct = header(headers, "content-type").unwrap_or("text/plain").to_string();
|
||||
if ct.trim_start().to_ascii_lowercase().starts_with("multipart/") {
|
||||
let Some(boundary) = param(&ct, "boundary").filter(|b| !b.is_empty() && b.len() <= MAX_BOUNDARY) else { return };
|
||||
if depth >= MAX_NESTING {
|
||||
return;
|
||||
}
|
||||
for chunk in multipart_bodies(body, boundary.as_bytes()) {
|
||||
let (h, b) = split_headers(chunk);
|
||||
collect(&h, b, out, depth + 1);
|
||||
if out.len() >= MAX_PARTS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let enc = header(headers, "content-transfer-encoding").unwrap_or("").trim().to_ascii_lowercase();
|
||||
let body = match enc.as_str() {
|
||||
"quoted-printable" => quoted_printable(body),
|
||||
"base64" => crate::model::base64_decode(&String::from_utf8_lossy(body)).unwrap_or_default(),
|
||||
_ => body.to_vec(),
|
||||
};
|
||||
let clean = |s: &str| s.trim().trim_matches(['<', '>']).to_string();
|
||||
out.push(Part {
|
||||
content_type: ct,
|
||||
location: header(headers, "content-location").map(clean),
|
||||
id: header(headers, "content-id").map(clean),
|
||||
body: Arc::new(body),
|
||||
});
|
||||
}
|
||||
|
||||
/// The bodies between `--boundary` lines, up to the closing `--boundary--`.
|
||||
fn multipart_bodies<'a>(body: &'a [u8], boundary: &[u8]) -> Vec<&'a [u8]> {
|
||||
let mut delim = b"--".to_vec();
|
||||
delim.extend_from_slice(boundary);
|
||||
let mut out = Vec::new();
|
||||
// Delimiters sit at line starts.
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(k) = body.get(i..).and_then(|rest| find_bytes(rest, &delim)) {
|
||||
let at = i + k;
|
||||
if at == 0 || body.get(at - 1) == Some(&b'\n') {
|
||||
starts.push(at);
|
||||
if starts.len() > MAX_PARTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
i = at + delim.len();
|
||||
}
|
||||
for w in starts.windows(2) {
|
||||
let (Some(&a), Some(&b)) = (w.first(), w.get(1)) else { continue };
|
||||
let Some(seg) = body.get(a + delim.len()..b) else { continue };
|
||||
if seg.starts_with(b"--") {
|
||||
break;
|
||||
}
|
||||
// The rest of the delimiter line, then the part; the line break before the next
|
||||
// delimiter belongs to it.
|
||||
let seg = match find_bytes(seg, b"\n") {
|
||||
Some(n) => seg.get(n + 1..).unwrap_or(&[]),
|
||||
None => &[],
|
||||
};
|
||||
let seg = seg.strip_suffix(b"\r\n").or_else(|| seg.strip_suffix(b"\n")).unwrap_or(seg);
|
||||
out.push(seg);
|
||||
if out.len() >= MAX_PARTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find_bytes(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Decode quoted-printable (RFC 2045 §6.7): `=XX` bytes and `=` soft line breaks; anything
|
||||
/// malformed is kept as it stands.
|
||||
fn quoted_printable(b: &[u8]) -> Vec<u8> {
|
||||
let hex = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok());
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
if c != b'=' {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
match (b.get(i + 1).copied(), b.get(i + 2).copied()) {
|
||||
(Some(b'\r'), Some(b'\n')) => i += 3,
|
||||
(Some(b'\n'), _) => i += 2,
|
||||
(Some(h), Some(l)) if let (Some(h), Some(l)) = (hex(h), hex(l)) => {
|
||||
out.push((h << 4) | l);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wordcraft_doc::{Block, InlineObject};
|
||||
|
||||
const PNG_1X1: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn message(html_part: &str) -> String {
|
||||
format!(
|
||||
"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"----=_B\"\r\n\r\n\
|
||||
------=_B\r\n{html_part}\r\n\
|
||||
------=_B\r\nContent-Location: file:///C:/page_files/dot.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n\
|
||||
------=_B--\r\n"
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_quoted_printable_page_and_its_pictures() {
|
||||
let page = "Content-Location: file:///C:/page.htm\r\nContent-Transfer-Encoding: quoted-printable\r\nContent-Type: text/html; charset=\"utf-8\"\r\n\r\n\
|
||||
<html><body><p class=3D\"x\">Dobar dan, gospo=C4=91o, ovo je =\r\njedan red.</p><p><img src=3D\"page_files/dot.png\" width=3D\"10\" height=3D\"10\"></p></body></html>";
|
||||
let d = import(message(page).as_bytes());
|
||||
let text = d.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
assert!(text.contains("Dobar dan, gospođo, ovo je jedan red."), "{text:?}");
|
||||
let pictures =
|
||||
d.body.iter().filter(|b| matches!(&***b, Block::Para(p) if p.objects.iter().any(|o| matches!(o, InlineObject::Image { .. })))).count();
|
||||
assert_eq!(pictures, 1);
|
||||
assert_eq!(d.media.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_named_relative_to_the_page_resolve_as_uris() {
|
||||
// The page in `reports/`, its picture in `images/` (RFC 2557 §5: relative URIs resolve
|
||||
// against the page's Content-Location).
|
||||
let m = |src: &str| {
|
||||
format!(
|
||||
"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\
|
||||
--b\r\nContent-Location: https://example.test/reports/page.html\r\nContent-Type: text/html\r\n\r\n<p>x</p><img src=\"{src}\">\r\n\
|
||||
--b\r\nContent-Location: https://example.test/images/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n"
|
||||
)
|
||||
};
|
||||
for src in [
|
||||
"../images/logo.png",
|
||||
"/images/logo.png",
|
||||
"./../images/./logo.png",
|
||||
"//example.test/images/logo.png",
|
||||
"https://example.test/images/logo.png",
|
||||
] {
|
||||
assert_eq!(import(m(src).as_bytes()).media.len(), 1, "{src}");
|
||||
}
|
||||
// Nothing past the root: `..` there stays at the root.
|
||||
assert_eq!(import(m("../../../images/logo.png").as_bytes()).media.len(), 1);
|
||||
assert_eq!(import(m("images/logo.png").as_bytes()).media.len(), 0);
|
||||
assert_eq!(resolve("https://h/a/b/c.html", "../d/./e.png#f"), "https://h/a/d/e.png");
|
||||
assert_eq!(resolve("file:///C:/x/page.htm", "page_files/dot.png"), "file:///C:/x/page_files/dot.png");
|
||||
assert_eq!(resolve("", "dot.png"), "dot.png");
|
||||
// A host with no path, and a base whose query holds slashes (RFC 3986 §5.2.3: the path
|
||||
// merges, not the query; an authority with an empty path gets its `/`).
|
||||
assert_eq!(resolve("https://example.test", "logo.png"), "https://example.test/logo.png");
|
||||
assert_eq!(resolve("https://example.test/page.html?redirect=/other/", "logo.png"), "https://example.test/logo.png");
|
||||
assert_eq!(resolve("https://h/a/p.html?q", "?r"), "https://h/a/p.html?r");
|
||||
assert_eq!(resolve("https://h/a/p.html?q", "x.png?v=1"), "https://h/a/x.png?v=1");
|
||||
assert_eq!(resolve("https://h/a/p.html", "mailto:x@y"), "mailto:x@y");
|
||||
for page in ["https://example.test", "https://example.test/page.html?redirect=/other/"] {
|
||||
let m = format!(
|
||||
"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=b\r\n\r\n\
|
||||
--b\r\nContent-Location: {page}\r\nContent-Type: text/html\r\n\r\n<p>x</p><img src=\"logo.png\">\r\n\
|
||||
--b\r\nContent-Location: https://example.test/logo.png\r\nContent-Transfer-Encoding: base64\r\nContent-Type: image/png\r\n\r\n{PNG_1X1}\r\n--b--\r\n"
|
||||
);
|
||||
assert_eq!(import(m.as_bytes()).media.len(), 1, "{page}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_part_charset_decides_the_text() {
|
||||
let page = "Content-Type: text/html; charset=windows-1250\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\n<p>=E8lanak</p>";
|
||||
let d = import(message(page).as_bytes());
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "članak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_part_is_the_page_and_cid_names_pictures() {
|
||||
let m = format!(
|
||||
"MIME-Version: 1.0\nContent-Type: multipart/related; type=\"text/html\"; start=\"<page>\"; boundary=b\n\n\
|
||||
--b\nContent-Type: text/html\n\n<p>Not this one</p>\n\
|
||||
--b\nContent-ID: <page>\nContent-Type: text/html\n\n<p>This one</p><img src=\"cid:dot\">\n\
|
||||
--b\nContent-ID: <dot>\nContent-Transfer-Encoding: base64\nContent-Type: image/png\n\n{PNG_1X1}\n--b--\n"
|
||||
);
|
||||
let d = import(m.as_bytes());
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "This one");
|
||||
assert_eq!(d.media.len(), 1);
|
||||
// Without a page, a message reads as its text.
|
||||
let d = import(b"MIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=b\n\n--b\nContent-Type: text/plain\n\nJust text\n--b--\n");
|
||||
assert_eq!(d.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Just text");
|
||||
assert!(is_mht(m.as_bytes()) && !is_mht(b"<html><p>x</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_messages_never_panic() {
|
||||
for m in [
|
||||
&b""[..],
|
||||
b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=\"\"\r\n\r\n",
|
||||
b"MIME-Version: 1.0\r\nContent-Type: multipart/related; boundary=x\r\n\r\n--x\r\n--x--",
|
||||
b"MIME-Version: 1.0\nContent-Type: multipart/related; boundary=x\n\n--x\nContent-Transfer-Encoding: quoted-printable\nContent-Type: text/html\n\n=\n=Z=4",
|
||||
b"Content-Type: text/html\r\nContent-Transfer-Encoding: base64\r\n\r\n!!!!",
|
||||
] {
|
||||
let _ = import(m);
|
||||
}
|
||||
let mut deep = String::from("MIME-Version: 1.0\r\n");
|
||||
for i in 0..64 {
|
||||
deep.push_str(&format!("Content-Type: multipart/related; boundary=b{i}\r\n\r\n--b{i}\r\n"));
|
||||
}
|
||||
let _ = import(deep.as_bytes());
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {
|
||||
mark_ins: None,
|
||||
mark_del: None,
|
||||
fmt_change: None,
|
||||
mark: None,
|
||||
};
|
||||
let in_code = kind == Kind::Code;
|
||||
let mut k = 0usize;
|
||||
@@ -179,6 +180,7 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, tracked: bool) -> Vec<Para> {
|
||||
mark_ins: None,
|
||||
mark_del: None,
|
||||
fmt_change: None,
|
||||
mark: None,
|
||||
};
|
||||
let prev = std::mem::replace(&mut out, next);
|
||||
if prev.inlines.is_empty() {
|
||||
@@ -320,6 +322,7 @@ fn flow_table(doc: &Document, t: &Table, depth: usize, tracked: bool) -> FTable
|
||||
shading: c.props.shading,
|
||||
borders: c.props.borders,
|
||||
valign: c.props.valign,
|
||||
width: None,
|
||||
});
|
||||
}
|
||||
rows.push(cells);
|
||||
|
||||
@@ -100,6 +100,12 @@ fn para_props(p: &Para, list_num: Option<u32>) -> ParaProps {
|
||||
out.indent_left = sp.left;
|
||||
out.indent_right = sp.right;
|
||||
out.indent_first = sp.first;
|
||||
if sp.before_auto {
|
||||
out.space_before_auto = Some(true);
|
||||
}
|
||||
if sp.after_auto {
|
||||
out.space_after_auto = Some(true);
|
||||
}
|
||||
if p.page_break {
|
||||
out.page_break_before = Some(true);
|
||||
}
|
||||
@@ -177,6 +183,9 @@ impl<'a> Builder<'a> {
|
||||
let old = para_props(&c.old, old_num);
|
||||
out.props.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old);
|
||||
}
|
||||
if let Some(f) = &p.mark {
|
||||
out.mark = self.budget(char_props(f));
|
||||
}
|
||||
if p.keep_next {
|
||||
out.props.keep_next = Some(true);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,9 @@ pub struct Spacing {
|
||||
pub right: Option<f32>,
|
||||
/// First-line indent; negative = hanging.
|
||||
pub first: Option<f32>,
|
||||
/// HTML's automatic space before / after (see `ParaProps::space_before_auto`).
|
||||
pub before_auto: bool,
|
||||
pub after_auto: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -244,6 +247,8 @@ pub struct Para {
|
||||
/// Tracked formatting change of the paragraph: the paragraph's formatting before it (kind,
|
||||
/// list, alignment, page break, spacing and borders of `old`; its inlines are unused).
|
||||
pub fmt_change: Option<Box<FmtChange<Para>>>,
|
||||
/// Formatting of an empty paragraph's mark (what sizes its line), when the source gives it.
|
||||
pub mark: Option<Box<Fmt>>,
|
||||
}
|
||||
|
||||
impl Para {
|
||||
@@ -337,11 +342,14 @@ pub struct Cell {
|
||||
/// The cell's own borders (RTF `\clbrdr…`), drawn whatever the table's style.
|
||||
pub borders: Option<wordcraft_doc::props::Borders>,
|
||||
pub valign: Option<VAlign>,
|
||||
/// The width, points, the source gives a cell spanning one column (HTML): see
|
||||
/// [`FTable::widths_from_cells`].
|
||||
pub width: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for Cell {
|
||||
fn default() -> Self {
|
||||
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None }
|
||||
Cell { blocks: Vec::new(), colspan: 1, rowspan: 1, covered: false, header: false, shading: None, borders: None, valign: None, width: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +380,22 @@ impl FTable {
|
||||
pub fn cols(&self) -> usize {
|
||||
self.rows.iter().map(|r| r.iter().map(|c| c.colspan.clamp(1, 63) as usize).sum::<usize>()).max().unwrap_or(0)
|
||||
}
|
||||
/// Grid column widths from the cells that give one (after [`Self::insert_covered`], so a
|
||||
/// cell's column counts those spanned from rows above), when every column has one.
|
||||
pub fn widths_from_cells(&mut self) {
|
||||
let mut widths = vec![None; self.cols()];
|
||||
for row in &self.rows {
|
||||
let mut g = 0;
|
||||
for c in row {
|
||||
if let (Some(w), Some(slot)) = (c.width, widths.get_mut(g)) {
|
||||
slot.get_or_insert(w);
|
||||
}
|
||||
g += c.colspan.clamp(1, 63) as usize;
|
||||
}
|
||||
}
|
||||
self.widths = widths.into_iter().collect::<Option<Vec<f32>>>().unwrap_or_default();
|
||||
}
|
||||
|
||||
/// After an importer that gives `rowspan` but no placeholder cells (HTML): insert covered
|
||||
/// cells into the rows below each spanning cell.
|
||||
pub fn insert_covered(&mut self) {
|
||||
|
||||
@@ -261,5 +261,13 @@ fn spacing(st: &St) -> model::Spacing {
|
||||
} else {
|
||||
Some(LineSpacing::AtLeast(pt(sl)))
|
||||
};
|
||||
model::Spacing { before: gap(st.sb), after: gap(st.sa), line, left: st.li.map(pt), right: st.ri.map(pt), first: st.fi.map(pt) }
|
||||
model::Spacing {
|
||||
before: gap(st.sb),
|
||||
after: gap(st.sa),
|
||||
line,
|
||||
left: st.li.map(pt),
|
||||
right: st.ri.map(pt),
|
||||
first: st.fi.map(pt),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,8 @@ pub type FileDialogAnswer = Result<Vec<String>, String>;
|
||||
const MAX_FONTS_PER_PICK: usize = 2000;
|
||||
|
||||
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "wmf", "emf"];
|
||||
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
|
||||
const DOCUMENTS: &[&str] =
|
||||
&["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "mht", "mhtml", "tex", "json", "xml"];
|
||||
|
||||
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
|
||||
/// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts asLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; previously trivial: DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef Line truncated
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -58,9 +58,9 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.odt` OpenDocument Text | 2% | R/W | 🟡 | 🟡 | ~50% | 8–12 | `crates/formats/src/tests.rs`, `crates/formats/src/odt.rs` unit tests, `crates/engine/tests/it/odt_sections.rs` | Styles, lists (#87), tables, pictures, page breaks (#86), headers and footers of each master page (every, left and first page) read on open, not yet written (#863), keep with next (#2813), an embedded formula object (`draw:object`) is read from its own `content.xml`'s MathML, same as HTML (#3099; export still drops equations, as before), page size, orientation and margins (#810), sections: one per master-page change on a paragraph or table (next page, with its own page size, orientation, margins, columns and page-numbering restart) and per columned `text:section` (continuous, with its columns and separator line) (#1379); export writes them back: a master page and page layout per new-page section (size, orientation, margins, columns, page-numbering restart on the first paragraph or table), a `text:section` with its columns per continuous section whose columns differ from the page's (one column included), a list cut by a section break goes on numbering. Limits: a continuous section's page-numbering restart isn't written (ODF applies it only with a master page); a continuous section with the page's columns merges into the section before it on reopening; even and odd page starts are written as plain new pages; a tracked deletion of a section break is lost; an empty last section is dropped; tracked insertions and deletions with author and date, inserted and deleted paragraph marks and text inserted then deleted included (ODF change tracking; format changes read as none; pictures and tables inside a tracked deletion are dropped). No footnotes, comments, fields or RTL |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) or a Single File Web Page (MHT) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`, `tests::a_single_file_web_page_saved_as_doc_opens_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~65% | 5–9 | `crates/formats/src/tests.rs`, `html.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). `style` element rules with simple selectors (`p`, `.x`, `p.x`; class names may start with a digit, as Word accepts) in cascade order; paragraph `margin`, `text-indent`, `line-height`; cell `width` and borders. Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). An opened page looks as Word opens one: Times New Roman 12 pt, single lines, browser-sized black headings, automatic 14 pt spacing where a `p` or heading states no margin (none before the first), a span's margin replacing its paragraph's. HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do (#1381). No `@page`, descendant selectors or `div` margins. No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | 🟡 | ❌ | ~60% | 2–4 | `crates/formats/src/mht.rs` | Opens: the MIME message's page (its `start` part, else the first HTML part) in its part's charset, quoted-printable or base64, pictures found by `Content-Location` (also relative to the page) or `cid:`; then read as an HTML page, on the region's paper. Not saved |
|
||||
| `.xml` Word XML Document (Flat OPC) | <1% | R/W | ✅ | ✅ | ~65% | — | `crates/docx/tests/it/word_xml.rs`, `roundtrip.rs` (`flat_opc_round_trip_equals_docx`) | The DOCX package in one XML file, read and written by the DOCX code (#548); File › Export › Word XML Document. `.xml` files are told apart by their root element |
|
||||
| `.xml` Word 2003 XML (WordprocessingML 2003) | <1% | R/W | 🟡 | 🟡 | ~55% | 1–2 | `crates/docx/tests/it/word_xml.rs` | Opened by rewriting it as a package for the DOCX reader (#548): styles, default fonts, lists, tables, sections, headers/footers, footnotes/endnotes, comments, bookmarks, tracked insertions/deletions, hyperlinks, `w:binData` pictures, document properties. Not read: formatting changes, `w:docPr` settings, smart tags. Written (#1081) by rewriting the DOCX writer's package into the 2003 vocabulary (`crates/docx/src/wordml2003_write.rs`, `crates/docx/tests/it/wordml2003_write.rs`): core properties, styles (document defaults folded into Normal), lists, text and paragraph/character formatting, tables, hyperlinks, footnotes/endnotes inline, headers/footers in the section properties, bookmarks, comments and tracked insertions/deletions as `aml:annotation`, section page setup, and PNG/JPEG/GIF/BMP/EMF/WMF pictures as `w:pict` with `w:binData`. **Dropped on write:** shapes, text boxes, groups, charts, SmartArt, ink, equations, OLE objects, picture crop, rotation and floating position (pictures become inline), content-control wrappers (their content stays), formatting-change tracking, theme colours/fonts, custom XML, macros, even/odd header setting. File › Export › Word 2003 XML Document, `file.save`/`file.saveAs` with `format: "wordml2003"`, `wordcraft-cli convert … --format wordml2003`; a plain save over a 2003 file keeps that format. Never opened in Word yet (written from the published schemas; needs a tester) |
|
||||
| `.wps` Works 6–9, WordPerfect `.wpd` (Windows) | <1% | R | ❌ | — | 0% | 10–20 | — | Windows-only converters; low value |
|
||||
@@ -121,10 +121,11 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen
|
||||
| Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it |
|
||||
| Ruby (`w:ruby`: `w:rubyPr`, `w:rt`, `w:rubyBase`) | ✅ | ✅ | #288; base and ruby text with their run formatting, alignment, sizes, raise, language |
|
||||
| Document grid (`w:docGrid`: type, line pitch, `w:charSpace`), `w:snapToGrid` on paragraphs, runs and styles | ✅ | ✅ | #391; run and character-style flags keep absent/true/false across repeated saves; inherited true remains explicit false when overridden. `w:webHidden` remains distinct from `w:vanish` and preserves absent/true/false through runs, styles and repeated saves; Print Layout still shows web-hidden text |
|
||||
| Alternative format chunks (`w:altChunk`) | ✅ | — | Brought into the document where they stand when it opens, as Word does: MHT, HTML and XHTML as web pages, RTF and plain text through their readers, a nested WordprocessingML package by the docx reader (at most 4 deep, 256 chunks and 512 MB of chunk data a file). The chunk's pictures, lists, styles, notes, comments and tracked changes come along (the document's own styles win by id, unless the chunk asks to keep its source formatting, `w:matchSrc`: then its styles come along under new ids, carrying its own defaults); its section breaks don't. Saving writes the content as ordinary paragraphs, as Word does. In a Word XML (Flat OPC) file only WordprocessingML chunks come in |
|
||||
| Two lines in one and horizontal in vertical (`w:eastAsianLayout`, §17.3.2.10) | ✅ | ✅ | Every attribute (`w:id`, `w:combine`, `w:combineBrackets`, `w:vert`, `w:vertCompress`) read into the run's formatting and written back after `w:lang`, in runs and styles; values outside the schema are dropped (`crates/docx/tests/it/east_asian_layout.rs`). Not laid out yet: the text shows as ordinary text |
|
||||
| `w:fitText` | ❌ | ❌ | East Asian layout |
|
||||
| Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` |
|
||||
| `w:altChunk`, sub-documents | ❌ | ❌ | |
|
||||
| Sub-documents | ❌ | ❌ | |
|
||||
| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Failed zip entries count against the size limit, case variants of a part name are one part, embedded fonts and charts are written within budgets, and Flat OPC inlines kept XML only when it is one well-formed element (2026-10-22) |
|
||||
| Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written |
|
||||
|
||||
@@ -281,6 +282,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists |
|
||||
| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle |
|
||||
| 2026-10-31 | trivial | .doc import: text naming no font takes the stylesheet's standard fonts (rgftcStandardChpStsh, ftcBi), not the font table's first entry; runs' East Asian and complex-script fonts are read (#5066) |
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split CLine truncated
|
||||
> **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged.
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no lonLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (#26: MHT opens and `w:altChunk` is brought in; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawiLine truncated
|
||||
|
||||
Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md).
|
||||
|
||||
@@ -90,7 +90,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc
|
||||
| 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) |
|
||||
| 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) |
|
||||
| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) |
|
||||
| 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 26 | **Minor formats**: saving MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word); MHT opens and documents' `w:altChunk` content is brought in | Rare | 12–23 | [file-format-parity.md](file-format-parity.md) |
|
||||
| 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) |
|
||||
| 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) |
|
||||
|
||||
@@ -154,6 +154,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains |
|
||||
| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged |
|
||||
| 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged |
|
||||
|
||||
Reference in new issue
Block a user