// F1 of the content writers: serialize semantic body content — paragraphs
// and runs with their formatting — into a schema-valid document. The
// Mammoth AST is the INPUT model only (per the roadmap's write contract);
// the package/resource context is the builder. FAIL-CLOSED: any
// `DocumentElement` this writer does not yet support raises `Unsupported`
// rather than silently dropping content (tables land in F2, lists/numbering
// in F3, hyperlinks/images in F4).
///|
/// Shared state of one `write_docx` invocation: which styles the body used
/// (drives the styles part) and whether any list numbering was written
/// (drives the numbering part and its wiring).
priv struct WriteContext {
used_styles : Set[String]
mut uses_lists : Bool
// Dynamically allocated document relationships (hyperlinks, images);
// rId1/rId2 are reserved for styles/numbering.
mut next_relationship : Int
document_relationships : Array[(String, String, String, Bool)]
// (part name under word/, extension, content type, bytes) per image.
media : Array[(String, String, String, Bytes)]
// How many footnotes/endnotes this write supplies: a NoteReference in
// a run must name one of them (0-based index), else it would dangle.
footnote_count : Int
endnote_count : Int
}
///|
fn WriteContext::allocate_relationship(
self : WriteContext,
relationship_type : String,
target : String,
external~ : Bool,
) -> String {
let id = "rId\{self.next_relationship}"
self.next_relationship += 1
self.document_relationships.push((id, relationship_type, target, external))
id
}
///|
/// Serializes body content into a complete docx package. Supported today:
/// paragraphs (style id, alignment, list numbering) containing runs (bold,
/// italic, underline, strikethrough, caps, super/subscript, font, size,
/// highlight), text, tabs, line breaks; and tables with spans. Headings use
/// the `Heading1`..`Heading6` style ids, emitted into the styles part so
/// Word's outline and this repo's own `outline` command both recognize
/// them; list paragraphs reference the fixed bullet/decimal definitions in
/// the numbering part.
pub fn write_docx(body : Array[DocumentElement]) -> Bytes raise DocxError {
// The whole pipeline lives in write_comments.mbt; an empty comment set
// is byte-identical to the historical no-comment writer.
write_docx_with_comments(body, [])
}
///|
const WORDPROCESSINGML_NAMESPACE : String = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
///|
const STRICT_WORDPROCESSINGML_NAMESPACE : String = "http://purl.oclc.org/ooxml/wordprocessingml/main"
///|
fn build_docx_package(
builder : @opc.PackageBuilder,
document_xml : String,
styles_xml : String,
ctx : WriteContext,
comments_xml~ : String?,
comments_extended_xml~ : String?,
footnotes_xml~ : String?,
endnotes_xml~ : String?,
limits? : @opc.PackageLimits,
) -> Bytes raise @opc.PackageBuildError {
builder.add_default(
"rels", "application/vnd.openxmlformats-package.relationships+xml",
)
builder.add_default("xml", "application/xml")
builder.add_override(
"/word/document.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
)
builder.add_override(
"/word/styles.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
)
builder.add_relationship(
"rId1", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
"word/document.xml",
)
builder.add_relationship(
"rId1",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
"styles.xml",
source="word/document.xml",
)
if ctx.uses_lists {
builder.add_override(
"/word/numbering.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
)
builder.add_relationship(
"rId2",
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering",
"numbering.xml",
source="word/document.xml",
)
builder.add_part("word/numbering.xml", @utf8.encode(numbering_xml()))
}
match comments_xml {
Some(xml) => {
builder.add_override(
"/word/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
)
// The main-part relationship to comments.xml was allocated in
// ctx.document_relationships and is added with the rest below.
builder.add_part("word/comments.xml", @utf8.encode(xml))
}
None => ()
}
match comments_extended_xml {
Some(xml) => {
builder.add_override(
"/word/commentsExtended.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml",
)
// Its main-part relationship is likewise in ctx.document_relationships.
builder.add_part("word/commentsExtended.xml", @utf8.encode(xml))
}
None => ()
}
match footnotes_xml {
Some(xml) => {
builder.add_override(
"/word/footnotes.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
)
builder.add_part("word/footnotes.xml", @utf8.encode(xml))
}
None => ()
}
match endnotes_xml {
Some(xml) => {
builder.add_override(
"/word/endnotes.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
)
builder.add_part("word/endnotes.xml", @utf8.encode(xml))
}
None => ()
}
let media_extensions : Set[String] = Set([])
for entry in ctx.media {
let (part_name, extension, content_type, data) = entry
if !media_extensions.contains(extension) {
media_extensions.add(extension)
builder.add_default(extension, content_type)
}
builder.add_part("word/\{part_name}", data)
}
for relationship in ctx.document_relationships {
let (id, relationship_type, target, external) = relationship
builder.add_relationship(
id,
relationship_type,
target,
source="word/document.xml",
external~,
)
}
builder.add_part("word/document.xml", @utf8.encode(document_xml))
builder.add_part("word/styles.xml", @utf8.encode(styles_xml))
match limits {
Some(l) => builder.build_limited(l)
None => builder.build()
}
}
///|
fn write_block(
element : DocumentElement,
ctx : WriteContext,
) -> XmlElement raise DocxError {
match element {
Paragraph(children~, properties~) =>
write_paragraph(children, properties, ctx)
Table(children~, properties~) => write_table(children, properties, ctx)
other =>
raise Unsupported(
message="the paragraph writer cannot serialize this element yet: \{block_kind_name(other)} (lists arrive in F3, media in F4)",
)
}
}
///|
fn write_paragraph(
children : Array[DocumentElement],
properties : ParagraphProperties,
ctx : WriteContext,
leading? : Array[XmlNode] = [],
trailing? : Array[XmlNode] = [],
) -> XmlElement raise DocxError {
let nodes : Array[XmlNode] = []
let ppr_children : Array[XmlNode] = []
match properties.style_id {
Some(style_id) => {
if !is_writable_style_id(style_id) {
raise Unsupported(
message="unsupported paragraph style id for the writer: '\{style_id}' (Heading1..Heading6 and Normal are supported)",
)
}
ctx.used_styles.add(style_id)
ppr_children.push(
XmlElement(
@xml.xml_element("w:pStyle", attributes={ "w:val": style_id }),
),
)
}
None => ()
}
match properties.numbering {
Some(numbering) => {
// The reader maps w:ilvl to level = ilvl + 1, so levels are 1-based
// here and written back as ilvl = level - 1. OOXML allows ilvl 0..8.
if numbering.level < 1 || numbering.level > 9 {
raise Unsupported(
message="list levels must be 1..9 (got \{numbering.level})",
)
}
ctx.uses_lists = true
let num_id = if numbering.is_ordered {
ORDERED_NUM_ID
} else {
UNORDERED_NUM_ID
}
ppr_children.push(
XmlElement(
@xml.xml_element("w:numPr", children=[
XmlElement(
@xml.xml_element("w:ilvl", attributes={
"w:val": (numbering.level - 1).to_string(),
}),
),
XmlElement(
@xml.xml_element("w:numId", attributes={ "w:val": num_id }),
),
]),
),
)
}
None => ()
}
match properties.alignment {
Some(alignment) => {
// ST_Jc values that are BOTH reader-producible and SDK-schema-valid;
// legacy 'justify' stays rejected (the SDK rejects it too).
if !(alignment
is ("left"
| "center"
| "right"
| "both"
| "start"
| "end"
| "distribute")) {
raise Unsupported(
message="unsupported paragraph alignment for the writer: '\{alignment}'",
)
}
ppr_children.push(
XmlElement(@xml.xml_element("w:jc", attributes={ "w:val": alignment })),
)
}
None => ()
}
// Anything else the AST can carry on a paragraph is not writable yet —
// fail closed rather than silently dropping reader-producible data.
if properties.indent.start is Some(_) ||
properties.indent.end is Some(_) ||
properties.indent.first_line is Some(_) ||
properties.indent.hanging is Some(_) {
raise Unsupported(
message="the paragraph writer cannot serialize indentation yet",
)
}
if properties.style_id is None && properties.style_name is Some(_) {
raise Unsupported(
message="the paragraph writer needs a style id; a bare style name cannot be serialized",
)
}
if ppr_children.length() > 0 {
nodes.push(XmlElement(@xml.xml_element("w:pPr", children=ppr_children)))
}
// Comment anchor markers: range starts go right after pPr (the locked
// canonical shape), range ends and reference runs after the content.
for node in leading {
nodes.push(node)
}
for child in children {
nodes.push(XmlElement(write_inline(child, ctx)))
}
for node in trailing {
nodes.push(node)
}
@xml.xml_element("w:p", children=nodes)
}
///|
fn write_inline(
element : DocumentElement,
ctx : WriteContext,
) -> XmlElement raise DocxError {
match element {
Run(children~, properties~) => write_run(children, properties, ctx)
Hyperlink(children~, href~, anchor~, target_frame~) =>
write_hyperlink(children, href, anchor, target_frame, ctx)
other =>
raise Unsupported(
message="the paragraph writer cannot serialize this inline element yet: \{block_kind_name(other)}",
)
}
}
///|
/// Word caps font size at 1638pt (ST_HpsMeasure half-points max 3276).
const MAX_FONT_SIZE_POINTS : Int = 1638
///|
/// The closed ST_HighlightColor enum (WML §17.18.40).
fn is_highlight_color(value : String) -> Bool {
value
is ("black"
| "blue"
| "cyan"
| "darkBlue"
| "darkCyan"
| "darkGray"
| "darkGreen"
| "darkMagenta"
| "darkRed"
| "darkYellow"
| "green"
| "lightGray"
| "magenta"
| "none"
| "red"
| "white"
| "yellow")
}
///|
/// Fail-closed guard for user text that lands in an XML attribute: C0
/// controls are either XML-1.0-illegal (the escaper passes them through)
/// or mutated by attribute-value normalization (tab/CR/LF become spaces),
/// so none can round-trip.
fn check_attribute_value(what : String, value : String) -> Unit raise DocxError {
for unit in value {
let code = unit.to_int()
if code < 0x20 {
raise Unsupported(
message="\{what} contains a control character (code \{code}) that cannot survive XML attribute normalization",
)
}
// Iteration combines surrogate pairs, so a surrogate here is unpaired;
// those and U+FFFE/U+FFFF are outside XML 1.0's character set.
if (code >= 0xD800 && code <= 0xDFFF) || code == 0xFFFE || code == 0xFFFF {
raise Unsupported(
message="\{what} contains a character XML cannot represent (code \{code}: unpaired surrogate or U+FFFE/U+FFFF)",
)
}
}
}
///|
fn write_run(
children : Array[DocumentElement],
properties : RunProperties,
ctx : WriteContext,
) -> XmlElement raise DocxError {
let nodes : Array[XmlNode] = []
// CT_RPr is an ENFORCED sequence — the Microsoft SDK rejects out-of-order
// children. Emission order here must stay: rFonts, b, i, caps, smallCaps,
// strike, sz, szCs, highlight, u, vertAlign.
let rpr : Array[XmlNode] = []
if properties.style_id is Some(_) || properties.style_name is Some(_) {
raise Unsupported(
message="the run writer cannot serialize character styles yet",
)
}
match properties.font {
Some(font) => {
check_attribute_value("a run font name", font)
rpr.push(
XmlElement(
@xml.xml_element("w:rFonts", attributes={
"w:ascii": font,
"w:hAnsi": font,
}),
),
)
}
None => ()
}
if properties.is_bold {
rpr.push(XmlElement(@xml.xml_element("w:b")))
}
if properties.is_italic {
rpr.push(XmlElement(@xml.xml_element("w:i")))
}
if properties.is_all_caps {
rpr.push(XmlElement(@xml.xml_element("w:caps")))
}
if properties.is_small_caps {
rpr.push(XmlElement(@xml.xml_element("w:smallCaps")))
}
if properties.is_strikethrough {
rpr.push(XmlElement(@xml.xml_element("w:strike")))
}
match properties.font_size {
Some(points) => {
// ST_HpsMeasure is a positive half-point measure and Word caps font
// size at 1638pt; outside that the doubled value is invalid OOXML.
if points < 1 || points > MAX_FONT_SIZE_POINTS {
raise Unsupported(
message="font size \{points}pt is outside the supported range 1..\{MAX_FONT_SIZE_POINTS}",
)
}
// The reader halves w:sz (half-points → points); the writer doubles
// back, so read(write(x)) preserves the value exactly.
let half_points = (points * 2).to_string()
rpr.push(
XmlElement(
@xml.xml_element("w:sz", attributes={ "w:val": half_points }),
),
)
rpr.push(
XmlElement(
@xml.xml_element("w:szCs", attributes={ "w:val": half_points }),
),
)
}
None => ()
}
match properties.highlight {
Some(highlight) => {
// w:highlight/@w:val is the closed ST_HighlightColor enum; anything
// else passes the structural belt but is schema-invalid.
if highlight == "none" {
raise Unsupported(
message="omit the highlight property instead of 'none' (the reader normalizes 'none' to absent, so it cannot round-trip)",
)
}
if !is_highlight_color(highlight) {
raise Unsupported(
message="unsupported highlight color '\{highlight}' (ST_HighlightColor names like yellow, green, cyan)",
)
}
rpr.push(
XmlElement(
@xml.xml_element("w:highlight", attributes={ "w:val": highlight }),
),
)
}
None => ()
}
if properties.is_underline {
rpr.push(
XmlElement(@xml.xml_element("w:u", attributes={ "w:val": "single" })),
)
}
match properties.vertical_alignment {
Baseline => ()
Superscript =>
rpr.push(
XmlElement(
@xml.xml_element("w:vertAlign", attributes={ "w:val": "superscript" }),
),
)
Subscript =>
rpr.push(
XmlElement(
@xml.xml_element("w:vertAlign", attributes={ "w:val": "subscript" }),
),
)
}
if rpr.length() > 0 {
nodes.push(XmlElement(@xml.xml_element("w:rPr", children=rpr)))
}
for child in children {
match child {
Text(text) => {
// A literal CR would be normalized (i.e. mutated) by conformant
// consumers, and a literal LF is crafted-file territory — fail
// closed; line breaks are written with `line_break()`.
if text.find("\r") is Some(_) || text.find("\n") is Some(_) {
raise Unsupported(
message="the run writer cannot serialize raw line-break characters in text; use line_break()",
)
}
// The XML escaper passes other C0 controls through and XML 1.0
// forbids them, so the SDK would reject the package. Tab is the
// one control legal in w:t.
for unit in text {
let code = unit.to_int()
if code < 0x20 && unit != '\t' {
raise Unsupported(
message="text contains an XML-illegal control character (code \{code})",
)
}
if (code >= 0xD800 && code <= 0xDFFF) ||
code == 0xFFFE ||
code == 0xFFFF {
raise Unsupported(
message="text contains a character XML cannot represent (code \{code}: unpaired surrogate or U+FFFE/U+FFFF)",
)
}
}
let attributes : Map[String, String] = Map([])
if text.has_prefix(" ") ||
text.has_suffix(" ") ||
text.has_prefix("\t") ||
text.has_suffix("\t") {
attributes["xml:space"] = "preserve"
}
nodes.push(
XmlElement(
@xml.xml_element("w:t", attributes~, children=[XmlText(text)]),
),
)
}
Tab => nodes.push(XmlElement(@xml.xml_element("w:tab")))
Break(Line) => nodes.push(XmlElement(@xml.xml_element("w:br")))
Image(image) => nodes.push(XmlElement(write_image(image, ctx)))
NoteReference(note_type~, note_id~) => {
// note_id is the 0-BASED INDEX into the notes supplied to
// write_docx_with_annotations; the emitted w:id is index+1
// (positive, clear of the -1/0 plumbing ids).
let count = match note_type {
"footnote" => ctx.footnote_count
"endnote" => ctx.endnote_count
other =>
raise Unsupported(
message="unknown note type '\{other}' (footnote or endnote)",
)
}
let index = match parse_note_index(note_id) {
Some(index) if index < count => index
_ =>
raise Unsupported(
message="the \{note_type} reference '\{note_id}' does not name a supplied note (this write has \{count} \{note_type}(s); references are 0-based indexes)",
)
}
let name = if note_type == "footnote" {
"w:footnoteReference"
} else {
"w:endnoteReference"
}
nodes.push(
XmlElement(
@xml.xml_element(name, attributes={
"w:id": (index + 1).to_string(),
}),
),
)
}
other =>
raise Unsupported(
message="the run writer cannot serialize this content yet: \{block_kind_name(other)}",
)
}
}
@xml.xml_element("w:r", children=nodes)
}
///|
/// Strict non-negative decimal parse of a note-reference index.
fn parse_note_index(text : String) -> Int? {
if text.length() == 0 || text.length() > 9 {
return None
}
let mut value = 0
for unit in text {
let code = unit.to_int()
if code < '0'.to_int() || code > '9'.to_int() {
return None
}
value = value * 10 + (code - '0'.to_int())
}
Some(value)
}
///|
fn blank_section_properties() -> XmlElement {
@xml.xml_element("w:sectPr", children=[
XmlElement(
@xml.xml_element("w:pgSz", attributes={ "w:w": "12240", "w:h": "15840" }),
),
XmlElement(
@xml.xml_element("w:pgMar", attributes={
"w:top": "1440",
"w:right": "1440",
"w:bottom": "1440",
"w:left": "1440",
"w:header": "720",
"w:footer": "720",
"w:gutter": "0",
}),
),
])
}
///|
fn is_writable_style_id(style_id : String) -> Bool {
if style_id == "Normal" {
return true
}
style_id.has_prefix("Heading") &&
style_id.length() == 8 &&
style_id[7] is ('1'..='6')
}
///|
/// Styles part: docDefaults + Normal, plus a definition for every heading
/// style the body used (name + outline level, based on Normal), so Word's
/// navigation pane and this repo's heading detection both work on the
/// output.
fn styles_xml_for(used_styles : Set[String]) -> String {
let styles : Array[XmlNode] = [
XmlElement(
@xml.xml_element("w:docDefaults", children=[
XmlElement(
@xml.xml_element("w:rPrDefault", children=[
XmlElement(
@xml.xml_element("w:rPr", children=[
XmlElement(
@xml.xml_element("w:sz", attributes={ "w:val": "24" }),
),
XmlElement(
@xml.xml_element("w:szCs", attributes={ "w:val": "24" }),
),
]),
),
]),
),
XmlElement(@xml.xml_element("w:pPrDefault")),
]),
),
XmlElement(
@xml.xml_element(
"w:style",
attributes={
"w:type": "paragraph",
"w:default": "1",
"w:styleId": "Normal",
},
children=[
XmlElement(
@xml.xml_element("w:name", attributes={ "w:val": "Normal" }),
),
],
),
),
]
for level in 1..<=6 {
let style_id = "Heading\{level}"
if !used_styles.contains(style_id) {
continue
}
styles.push(
XmlElement(
@xml.xml_element(
"w:style",
attributes={ "w:type": "paragraph", "w:styleId": style_id },
children=[
XmlElement(
@xml.xml_element("w:name", attributes={
"w:val": "heading \{level}",
}),
),
XmlElement(
@xml.xml_element("w:basedOn", attributes={ "w:val": "Normal" }),
),
XmlElement(
@xml.xml_element("w:pPr", children=[
XmlElement(
@xml.xml_element("w:outlineLvl", attributes={
"w:val": (level - 1).to_string(),
}),
),
]),
),
],
),
),
)
}
@xml.write_xml_string(@xml.xml_element("w:styles", children=styles), namespaces={
"w": WORDPROCESSINGML_NAMESPACE,
})
}
///|
fn block_kind_name(element : DocumentElement) -> String {
match element {
Document(..) => "document"
Paragraph(..) => "paragraph"
Run(..) => "run"
Text(_) => "text"
Tab => "tab"
Checkbox(_) => "checkbox"
Hyperlink(..) => "hyperlink"
NoteReference(..) => "note reference"
CommentReference(_) => "comment reference"
Image(_) => "image"
Table(..) => "table"
TableRow(..) => "table row"
TableCell(..) => "table cell"
Break(_) => "break"
BookmarkStart(_) => "bookmark"
}
}