// The `docx.batch/1` op-script parser and body builder — the consumed
// (input) schema behind `docx batch`. STRICT per the roadmap contract:
// unknown schema, unknown op, unknown key, or a wrong value type fails
// with an error naming the 0-based op index; nothing is repaired. Two
// phases so the pure parts stay pure: `parse_batch` validates the script
// and collects image paths; `build_body` turns the parsed script plus the
// (CLI-loaded) image bytes into writer input.
///|
/// A script that cannot be parsed or applied. Messages are agent-facing
/// and name the op index and key.
pub(all) suberror BatchError {
BatchError(String)
} derive(Eq)
///|
pub impl Show for BatchError with fn output(self, logger) {
match self {
BatchError(message) => logger.write_string(message)
}
}
///|
/// The original schema identifier accepted by `docx batch` scripts:
/// paragraph and table ops only.
pub const SCHEMA_BATCH : String = "docx.batch/1"
///|
/// The current major: everything in `docx.batch/1` plus `comment` ops.
/// Consumed schemas widen by new major (the roadmap's rule) — scripts
/// declaring either version parse, but `comment` needs `/2`.
pub const SCHEMA_BATCH_V2 : String = "docx.batch/2"
///|
/// Maximum number of operations in one DOCX authoring script.
pub const MAX_BATCH_OPS : Int = 10000
///|
/// Bounds mirroring the writer's fail-closed guards, enforced here too so
/// violations are reported with the exact `ops[i]` address instead of a
/// whole-document writer error: Word's font-size cap (write_document.mbt)
/// and Word's 63-column table limit (write_tables.mbt). Row spans are
/// bounded by the table's remaining rows, exactly like the writer.
pub const MAX_FONT_SIZE_POINTS : Int = 1638
///|
/// Maximum grid-column count accepted by the DOCX table writer.
pub const MAX_GRID_COLUMNS : Int = 63
///|
/// The operation discriminator vocabulary accepted by this build. The Office
/// input-contract help consumes this parser-owned inventory instead of
/// maintaining a second list.
let batch_operation_names : Array[String] = ["paragraph", "table", "comment"]
///|
/// Returns the operation discriminator vocabulary in stable order.
pub fn operation_names() -> Array[String] {
batch_operation_names.copy()
}
///|
/// Paragraph styles the writer can serialize (write_document.mbt's
/// is_writable_style_id).
fn is_writable_style(style : String) -> Bool {
style
is ("Normal"
| "Heading1"
| "Heading2"
| "Heading3"
| "Heading4"
| "Heading5"
| "Heading6")
}
///|
/// The writer's alignment whitelist (write_document.mbt).
fn is_writable_alignment(align : String) -> Bool {
align
is ("left" | "center" | "right" | "both" | "start" | "end" | "distribute")
}
///|
/// The closed ST_HighlightColor enum the writer accepts.
fn is_highlight_color(value : String) -> Bool {
value
is ("black"
| "blue"
| "cyan"
| "darkBlue"
| "darkCyan"
| "darkGray"
| "darkGreen"
| "darkMagenta"
| "darkRed"
| "darkYellow"
| "green"
| "lightGray"
| "magenta"
| "red"
| "white"
| "yellow")
}
///|
priv enum InlineSpec {
RunSpec(@document.RunProperties, String)
LinkSpec(
href~ : String?,
anchor~ : String?,
target_frame~ : String?,
runs~ : Array[InlineSpec]
)
ImageSpec(
path~ : String,
content_type~ : String,
alt~ : String?,
at~ : String
)
// A run holding one footnote/endnote reference; index is the dense
// per-kind note index the writer consumes.
NoteRefSpec(note_type~ : String, index~ : Int)
}
///|
/// Note bodies collected while parsing body runs. Present only where
/// notes are ALLOWED: docx.batch/2 body content (including table
/// cells), never inside comment or note bodies, never under /1.
priv struct NoteCollector {
footnotes : Array[(String, Array[ParagraphSpec])]
endnotes : Array[(String, Array[ParagraphSpec])]
}
///|
priv struct CellSpec {
paragraphs : Array[ParagraphSpec]
col_span : Int
row_span : Int
}
///|
priv struct ParagraphSpec {
style : String?
align : String?
list : @document.Numbering?
inlines : Array[InlineSpec]
}
///|
priv enum OpSpec {
ParagraphOp(ParagraphSpec)
TableOp(rows~ : Array[Array[CellSpec]], header_rows~ : Int)
}
///|
/// What each SCRIPT op (comment ops included) turned into, so comment
/// anchors can be checked against the kind of the op they reference and
/// mapped to the body BLOCK index it produced.
priv enum ParsedOpKind {
ParagraphAt(Int)
Table
// Carries the comment's dense id (its index among comment ops), the
// id replies thread to.
Comment(Int)
}
///|
/// One parsed `comment` op: metadata verbatim, anchors already mapped
/// from op indexes to body block indexes, body as paragraph specs.
priv enum CommentAnchor {
AnchorBlocks(from~ : Int, to~ : Int)
ReplyToComment(Int)
}
///|
priv struct CommentOp {
at : String
author : String
initials : String?
date : String?
anchor : CommentAnchor
done : Bool?
body : Array[ParagraphSpec]
}
///|
/// A parsed, validated batch script.
pub struct BatchScript {
priv ops : Array[OpSpec]
priv image_paths : Array[String]
priv comments : Array[CommentOp]
priv footnotes : Array[(String, Array[ParagraphSpec])]
priv endnotes : Array[(String, Array[ParagraphSpec])]
priv total_ops : Int
// The script op index behind each body BLOCK (comment ops produce no
// blocks, so block index i came from ops[block_ops[i]]).
priv block_ops : Array[Int]
}
///|
/// The image files the script references, in first-use order (deduplicated).
/// The caller loads them and passes the bytes to `build_body`.
pub fn BatchScript::image_paths(self : BatchScript) -> Array[String] {
self.image_paths
}
///|
/// Number of ops in the script, comment ops included.
pub fn BatchScript::op_count(self : BatchScript) -> Int {
self.total_ops
}
///|
/// Number of `comment` ops in the script.
pub fn BatchScript::comment_count(self : BatchScript) -> Int {
self.comments.length()
}
///|
/// The script op index that produced body block `block` (what
/// `build_body` returns at that position), or None out of range.
/// Comment ops produce no blocks, so this is NOT the identity once a
/// script holds comments — writer failures on dynamic content must be
/// attributed through this mapping.
pub fn BatchScript::block_op(self : BatchScript, block : Int) -> Int? {
self.block_ops.get(block)
}
///|
/// The script address (`ops[i]...image`) that first references `path`,
/// so callers can attribute file-loading failures to the exact op.
pub fn BatchScript::image_reference(
self : BatchScript,
path : String,
) -> String? {
fn scan_inlines(inlines : Array[InlineSpec]) -> String? {
for inline in inlines {
match inline {
ImageSpec(path=candidate, at~, ..) if candidate == path =>
return Some(at)
LinkSpec(runs~, ..) =>
if scan_inlines(runs) is Some(found) {
return Some(found)
}
_ => ()
}
}
None
}
fn scan_paragraphs(paragraphs : Array[ParagraphSpec]) -> String? {
for paragraph in paragraphs {
if scan_inlines(paragraph.inlines) is Some(found) {
return Some(found)
}
}
None
}
for op in self.ops {
let found = match op {
ParagraphOp(spec) => scan_inlines(spec.inlines)
TableOp(rows~, ..) => {
let mut in_table : String? = None
for cells in rows {
for cell in cells {
if scan_paragraphs(cell.paragraphs) is Some(found) {
in_table = Some(found)
break
}
}
if in_table is Some(_) {
break
}
}
in_table
}
}
if found is Some(_) {
return found
}
}
None
}
///|
/// Every image reference in document order, INCLUDING duplicates — one entry
/// per media part the writer will emit (`image_paths()` deduplicates for
/// loading, but each occurrence becomes a distinct part). Fresh-authoring
/// callers bound this against their emitted-media ceilings BEFORE building the
/// writer graph, so a script with thousands of references to one asset is
/// refused cheaply rather than after materializing every part.
pub fn BatchScript::image_occurrences(self : BatchScript) -> Array[String] {
let occurrences : Array[String] = []
fn scan_inlines(inlines : Array[InlineSpec]) -> Unit {
for inline in inlines {
match inline {
ImageSpec(path~, ..) => occurrences.push(path)
LinkSpec(runs~, ..) => scan_inlines(runs)
_ => ()
}
}
}
fn scan_paragraphs(paragraphs : Array[ParagraphSpec]) -> Unit {
for paragraph in paragraphs {
scan_inlines(paragraph.inlines)
}
}
for op in self.ops {
match op {
ParagraphOp(spec) => scan_inlines(spec.inlines)
TableOp(rows~, ..) =>
for cells in rows {
for cell in cells {
scan_paragraphs(cell.paragraphs)
}
}
}
}
occurrences
}
///|
/// Parses and strictly validates a `docx.batch/1` or `docx.batch/2`
/// script (`comment` ops need the `/2` declaration).
pub fn parse_batch(script : Json) -> BatchScript raise BatchError {
guard script is Object(root) else {
raise BatchError("the script must be a JSON object")
}
check_keys(root, ["schema", "ops"], "the script")
guard root.get("schema") is Some(String(schema)) else {
raise BatchError(
"the script needs \"schema\": \"\{SCHEMA_BATCH}\" or \"\{SCHEMA_BATCH_V2}\"",
)
}
if schema != SCHEMA_BATCH && schema != SCHEMA_BATCH_V2 {
raise BatchError(
"unsupported schema '\{schema}' (this build accepts \{SCHEMA_BATCH} and \{SCHEMA_BATCH_V2})",
)
}
guard root.get("ops") is Some(Array(ops_json)) else {
raise BatchError("the script needs \"ops\": [...]")
}
if ops_json.length() > MAX_BATCH_OPS {
raise BatchError(
"the script has \{ops_json.length()} ops; the cap is \{MAX_BATCH_OPS}",
)
}
let ops : Array[OpSpec] = []
let image_paths : Array[String] = []
let seen_paths : StableStringSet = SortedSet([])
let kinds : Array[ParsedOpKind] = []
let comments : Array[CommentOp] = []
let block_ops : Array[Int] = []
// Notes only exist in /2; a None collector makes every note key an
// addressed schema error under /1.
let notes : NoteCollector? = if schema == SCHEMA_BATCH_V2 {
Some({ footnotes: [], endnotes: [] })
} else {
None
}
for index, op_json in ops_json {
let at = "ops[\{index}]"
guard op_json is Object(op) else {
raise BatchError("\{at} must be an object")
}
check_keys(op, ["op", "params"], at)
guard op.get("op") is Some(String(op_name)) else {
raise BatchError("\{at}.op must be a string")
}
let params = match op.get("params") {
Some(Object(params)) => params
Some(_) => raise BatchError("\{at}.params must be an object")
None => Map([])
}
if !batch_operation_names.contains(op_name) {
raise BatchError(
"\{at}.op '\{op_name}' is unknown (known ops: paragraph, table, comment)",
)
}
match op_name {
"paragraph" => {
kinds.push(ParagraphAt(ops.length()))
block_ops.push(index)
ops.push(
ParagraphOp(
parse_paragraph(
params,
at + ".params",
image_paths,
seen_paths,
notes~,
),
),
)
}
"table" => {
kinds.push(Table)
block_ops.push(index)
ops.push(parse_table(params, at, image_paths, seen_paths, notes~))
}
"comment" => {
if schema == SCHEMA_BATCH {
raise BatchError(
"\{at}.op 'comment' needs \"schema\": \"\{SCHEMA_BATCH_V2}\" (this script declares \{SCHEMA_BATCH})",
)
}
kinds.push(Comment(comments.length()))
comments.push(
parse_comment(params, index, at, kinds, image_paths, seen_paths),
)
}
other =>
raise BatchError(
"\{at}.op '\{other}' is unknown (known ops: paragraph, table, comment)",
)
}
}
let (footnotes, endnotes) = match notes {
Some(collector) => (collector.footnotes, collector.endnotes)
None => ([], [])
}
{
ops,
image_paths,
comments,
footnotes,
endnotes,
total_ops: ops_json.length(),
block_ops,
}
}
///|
/// Parses one `comment` op. `on` names the anchored range by OP index —
/// a single integer or `{"from": i, "to": j}` (inclusive, ordered) —
/// where every endpoint must be an EARLIER paragraph op: tables cannot
/// carry the canonical intra-paragraph anchor shape, comments have no
/// block of their own to anchor to, and self/forward references would
/// name content that does not exist yet. The body (`text` or
/// `paragraphs`) is PLAIN CONTENT: hyperlinks and images are rejected
/// because the comments part gets no relationships.
fn parse_comment(
params : Map[String, Json],
op_index : Int,
at : String,
kinds : Array[ParsedOpKind],
image_paths : Array[String],
seen_paths : StableStringSet,
) -> CommentOp raise BatchError {
let at_params = at + ".params"
check_keys(
params,
[
"on", "reply_to", "text", "paragraphs", "author", "initials", "date", "done",
],
at_params,
)
guard params.get("author") is Some(author_json) else {
raise BatchError("\{at_params} needs \"author\"")
}
guard author_json is String(author) else {
raise BatchError("\{at_params}.author must be a string")
}
if author == "" {
raise BatchError("\{at_params}.author must not be empty")
}
let _ = checked_attribute(Some(author), at_params + ".author")
let initials = checked_attribute(
optional_string(params, "initials", at_params),
at_params + ".initials",
)
// The date is validated LEXICALLY by the writer (xsd:dateTime and
// emitted verbatim); here it only has to be a string.
let date = optional_string(params, "date", at_params)
fn endpoint(value : Json, where_ : String) -> (Int, Int) raise BatchError {
guard value is Number(number, ..) else {
raise BatchError("\{where_} must be an op index (an integer)")
}
let idx = number.to_int()
if number != number || idx.to_double() != number {
raise BatchError("\{where_} must be an integer (got \{number})")
}
if idx < 0 || idx >= op_index {
if op_index == 0 {
raise BatchError(
"\{where_}: ops[0] is the first op, so there is nothing earlier to anchor to (put comments AFTER the content they annotate)",
)
}
raise BatchError(
"\{where_} must reference an EARLIER op (0..\{op_index - 1}, got \{idx}); comments anchor to content that already exists",
)
}
match kinds[idx] {
ParagraphAt(block) => (idx, block)
Table =>
raise BatchError(
"\{where_} targets a table (ops[\{idx}]); comment anchors must be paragraph ops",
)
Comment(_) =>
raise BatchError(
"\{where_} targets another comment (ops[\{idx}]); comment anchors must be paragraph ops (to answer a comment, use reply_to)",
)
}
}
let done : Bool? = match params.get("done") {
Some(True) => Some(true)
Some(False) => Some(false)
Some(_) => raise BatchError("\{at_params}.done must be a boolean")
None => None
}
// A comment is ANCHORED (`on`) xor a REPLY (`reply_to`): a reply's
// anchor is its parent's, so giving it one of its own is an error.
match (params.get("on"), params.get("reply_to")) {
(Some(_), Some(_)) =>
raise BatchError(
"\{at_params}: on and reply_to are mutually exclusive (a reply inherits its parent's anchor)",
)
(None, Some(reply_json)) => {
guard reply_json is Number(number, ..) else {
raise BatchError(
"\{at_params}.reply_to must be an op index (an integer)",
)
}
let idx = number.to_int()
if number != number || idx.to_double() != number {
raise BatchError(
"\{at_params}.reply_to must be an integer (got \{number})",
)
}
if idx < 0 || idx >= op_index {
raise BatchError(
"\{at_params}.reply_to must reference an EARLIER op (got \{idx}); replies answer comments that already exist",
)
}
guard kinds[idx] is Comment(parent) else {
raise BatchError(
"\{at_params}.reply_to targets ops[\{idx}], which is not a comment op (replies answer comments; use on to anchor to content)",
)
}
let body = parse_comment_body(params, at_params, image_paths, seen_paths)
return {
at,
author,
initials,
date,
anchor: ReplyToComment(parent),
done,
body,
}
}
(None, None) =>
raise BatchError(
"\{at_params} needs \"on\" (an earlier paragraph-op index, or {\"from\": i, \"to\": j}) or \"reply_to\" (an earlier comment-op index)",
)
(Some(_), None) => ()
}
guard params.get("on") is Some(on_json) else {
raise BatchError("\{at_params} needs \"on\"")
}
let (from_block, to_block) = match on_json {
Number(_, ..) => {
let (_, block) = endpoint(on_json, at_params + ".on")
(block, block)
}
Object(range) => {
check_keys(range, ["from", "to"], at_params + ".on")
guard range.get("from") is Some(from_json) else {
raise BatchError("\{at_params}.on needs \"from\"")
}
guard range.get("to") is Some(to_json) else {
raise BatchError("\{at_params}.on needs \"to\"")
}
let (from_op, from_block) = endpoint(from_json, at_params + ".on.from")
let (to_op, to_block) = endpoint(to_json, at_params + ".on.to")
if to_block < from_block {
raise BatchError(
"\{at_params}.on: \"to\" (ops[\{to_op}]) precedes \"from\" (ops[\{from_op}]); the range is inclusive and must be ordered",
)
}
(from_block, to_block)
}
_ =>
raise BatchError(
"\{at_params}.on must be an op index or {\"from\": i, \"to\": j}",
)
}
let body = parse_comment_body(params, at_params, image_paths, seen_paths)
{
at,
author,
initials,
date,
anchor: AnchorBlocks(from=from_block, to=to_block),
done,
body,
}
}
///|
/// The comment body: exactly one of `text` (one plain paragraph) or
/// `paragraphs` (non-empty, each parsed with the plain-content rule).
fn parse_comment_body(
params : Map[String, Json],
at_params : String,
image_paths : Array[String],
seen_paths : StableStringSet,
) -> Array[ParagraphSpec] raise BatchError {
let body : Array[ParagraphSpec] = []
match (params.get("text"), params.get("paragraphs")) {
(Some(_), Some(_)) =>
raise BatchError(
"\{at_params}: text and paragraphs are mutually exclusive",
)
(Some(String(text)), None) => {
check_text(text, at_params + ".text")
body.push({
style: None,
align: None,
list: None,
inlines: [RunSpec(@document.run_properties(), text)],
})
}
(Some(_), None) => raise BatchError("\{at_params}.text must be a string")
(None, Some(Array(paragraphs_json))) => {
if paragraphs_json.length() == 0 {
raise BatchError("\{at_params}.paragraphs must not be empty")
}
for paragraph_index, paragraph_json in paragraphs_json {
guard paragraph_json is Object(paragraph) else {
raise BatchError(
"\{at_params}.paragraphs[\{paragraph_index}] must be an object",
)
}
body.push(
parse_paragraph(
paragraph,
"\{at_params}.paragraphs[\{paragraph_index}]",
image_paths,
seen_paths,
plain=true,
),
)
}
}
(None, Some(_)) =>
raise BatchError("\{at_params}.paragraphs must be an array")
(None, None) =>
raise BatchError(
"\{at_params} needs text or paragraphs (the comment body)",
)
}
body
}
///|
fn check_keys(
object : Map[String, Json],
allowed : Array[String],
at : String,
) -> Unit raise BatchError {
for key, _ in object {
if allowed.search(key) is None {
raise BatchError(
"\{at} has an unknown key '\{key}' (allowed: \{allowed.join(", ")})",
)
}
}
}
///|
fn parse_paragraph(
params : Map[String, Json],
at : String,
image_paths : Array[String],
seen_paths : StableStringSet,
plain? : Bool = false,
notes? : NoteCollector? = None,
) -> ParagraphSpec raise BatchError {
// `at` is the address of the paragraph object itself ("ops[3].params",
// "ops[3].params.rows[0][1].paragraphs[2]") so every message below
// points at the exact JSON node.
check_keys(params, ["text", "runs", "style", "align", "list"], at)
let style = match optional_string(params, "style", at) {
Some(style) => {
if !is_writable_style(style) {
raise BatchError(
"\{at}.style '\{style}' is unknown (known styles: Normal, Heading1..Heading6)",
)
}
Some(style)
}
None => None
}
let align = match optional_string(params, "align", at) {
Some(align) => {
if !is_writable_alignment(align) {
raise BatchError(
"\{at}.align '\{align}' is unknown (known: left, center, right, both, start, end, distribute)",
)
}
Some(align)
}
None => None
}
let list = match params.get("list") {
Some(Object(list_json)) => {
check_keys(list_json, ["ordered", "level"], at + ".list")
let ordered = match list_json.get("ordered") {
Some(True) => true
Some(False) => false
Some(_) => raise BatchError("\{at}.list.ordered must be a boolean")
None =>
raise BatchError(
"\{at}.list needs \"ordered\" (true = numbered, false = bulleted)",
)
}
let level = int_value(
list_json,
"level",
at + ".list",
min=1,
max=9,
default=1,
)
Some(@document.numbering(ordered, level))
}
Some(_) => raise BatchError("\{at}.list must be an object")
None => None
}
let inlines : Array[InlineSpec] = []
match (params.get("text"), params.get("runs")) {
(Some(_), Some(_)) =>
raise BatchError("\{at}: text and runs are mutually exclusive")
(Some(String(text)), None) => {
check_text(text, at + ".text")
inlines.push(RunSpec(@document.run_properties(), text))
}
(Some(_), None) => raise BatchError("\{at}.text must be a string")
(None, Some(Array(runs))) => {
if runs.length() == 0 {
raise BatchError(
"\{at}.runs must not be empty (use \"text\": \"\" for a blank paragraph)",
)
}
for run_index, run_json in runs {
inlines.push(
parse_inline(
run_json,
"\{at}.runs[\{run_index}]",
image_paths,
seen_paths,
allow_links=true,
plain~,
notes~,
),
)
}
}
(None, Some(_)) => raise BatchError("\{at}.runs must be an array")
(None, None) =>
raise BatchError(
"\{at} needs text or runs (use \"text\": \"\" for a blank paragraph)",
)
}
{ style, align, list, inlines }
}
///|
fn parse_inline(
json : Json,
at : String,
image_paths : Array[String],
seen_paths : StableStringSet,
allow_links~ : Bool,
plain? : Bool = false,
notes? : NoteCollector? = None,
) -> InlineSpec raise BatchError {
guard json is Object(spec) else {
raise BatchError("\{at} must be an object")
}
if spec.get("link") is Some(link_json) {
if plain {
raise BatchError(
"\{at}: hyperlinks are not allowed in comment bodies (plain content only)",
)
}
if !allow_links {
raise BatchError("\{at}: links cannot nest inside links")
}
check_keys(spec, ["link"], at)
guard link_json is Object(link) else {
raise BatchError("\{at}.link must be an object")
}
check_keys(
link,
["href", "anchor", "target_frame", "text", "runs"],
at + ".link",
)
let href = checked_attribute(
optional_string(link, "href", at + ".link"),
at + ".link.href",
)
let anchor = checked_attribute(
optional_string(link, "anchor", at + ".link"),
at + ".link.anchor",
)
let target_frame = checked_attribute(
optional_string(link, "target_frame", at + ".link"),
at + ".link.target_frame",
)
// The writer requires exactly one of href/anchor (it folds both into
// the reader's href-fragment representation); enforce here so the
// error carries the link's address.
match (href, anchor) {
(Some(_), Some(_)) =>
raise BatchError(
"\{at}.link: href and anchor are mutually exclusive (put a fragment in the href instead)",
)
(None, None) => raise BatchError("\{at}.link needs an href or an anchor")
_ => ()
}
let runs : Array[InlineSpec] = []
match (link.get("text"), link.get("runs")) {
(Some(String(text)), None) => {
check_text(text, at + ".link.text")
runs.push(RunSpec(@document.run_properties(), text))
}
(Some(_), None) => raise BatchError("\{at}.link.text must be a string")
(None, Some(Array(link_runs))) => {
if link_runs.length() == 0 {
raise BatchError("\{at}.link.runs must not be empty")
}
for run_index, run_json in link_runs {
runs.push(
parse_inline(
run_json,
"\{at}.link.runs[\{run_index}]",
image_paths,
seen_paths,
allow_links=false,
plain~,
notes~,
),
)
}
}
(None, Some(_)) => raise BatchError("\{at}.link.runs must be an array")
(Some(_), Some(_)) =>
raise BatchError("\{at}.link: text and runs are mutually exclusive")
(None, None) => raise BatchError("\{at}.link needs text or runs")
}
return LinkSpec(href~, anchor~, target_frame~, runs~)
}
for note_type in (["footnote", "endnote"] : ReadOnlyArray[String]) {
guard spec.get(note_type) is Some(note_json) else { continue }
if plain {
raise BatchError("\{at}: notes cannot nest inside comment or note bodies")
}
// allow_links=false means we are INSIDE a hyperlink's runs: keep
// note references at top run level (the canonical shape).
if !allow_links {
raise BatchError(
"\{at}.\{note_type}: notes cannot sit inside hyperlinks (put the note reference in a top-level run)",
)
}
guard notes is Some(collector) else {
raise BatchError(
"\{at}.\{note_type} needs \"schema\": \"\{SCHEMA_BATCH_V2}\"",
)
}
check_keys(spec, [note_type], at)
guard note_json is Object(note) else {
raise BatchError("\{at}.\{note_type} must be an object")
}
check_keys(note, ["text", "paragraphs"], "\{at}.\{note_type}")
let body = parse_comment_body(
note,
"\{at}.\{note_type}",
image_paths,
seen_paths,
)
let bodies = if note_type == "footnote" {
collector.footnotes
} else {
collector.endnotes
}
let index = bodies.length()
bodies.push(("\{at}.\{note_type}", body))
return NoteRefSpec(note_type~, index~)
}
if spec.get("image") is Some(image_json) {
if plain {
raise BatchError(
"\{at}: images are not allowed in comment bodies (plain content only)",
)
}
check_keys(spec, ["image"], at)
guard image_json is Object(image) else {
raise BatchError("\{at}.image must be an object")
}
check_keys(image, ["path", "content_type", "alt"], at + ".image")
guard image.get("path") is Some(String(path)) else {
raise BatchError("\{at}.image.path must be a string")
}
let content_type = match
optional_string(image, "content_type", at + ".image") {
Some(content_type) => content_type
None => content_type_for_path(path, at)
}
if !(content_type is ("image/png" | "image/jpeg" | "image/gif")) {
raise BatchError(
"\{at}.image.content_type '\{content_type}' is unsupported (image/png, image/jpeg, image/gif)",
)
}
let alt = checked_attribute(
optional_string(image, "alt", at + ".image"),
at + ".image.alt",
)
match alt {
Some(text) if text.trim(chars=" \t") == "" =>
raise BatchError("\{at}.image.alt must not be blank")
_ => ()
}
if !seen_paths.contains(path) {
seen_paths.add(path)
image_paths.push(path)
}
return ImageSpec(path~, content_type~, alt~, at=at + ".image")
}
check_keys(
spec,
[
"text", "bold", "italic", "underline", "strike", "all_caps", "small_caps",
"vertical", "font", "size", "highlight",
],
at,
)
guard spec.get("text") is Some(String(text)) else {
raise BatchError("\{at}.text must be a string")
}
check_text(text, at + ".text")
let vertical = match optional_string(spec, "vertical", at) {
Some("superscript") => @document.Superscript
Some("subscript") => Subscript
Some(other) =>
raise BatchError(
"\{at}.vertical must be 'superscript' or 'subscript' (got '\{other}')",
)
None => Baseline
}
let size = if spec.get("size") is Some(_) {
Some(
int_value(spec, "size", at, min=1, max=MAX_FONT_SIZE_POINTS, default=0),
)
} else {
None
}
let highlight = match optional_string(spec, "highlight", at) {
Some(highlight) => {
if highlight == "none" {
raise BatchError(
"\{at}.highlight: omit the key instead of 'none' (the reader normalizes 'none' to absent, so it cannot round-trip)",
)
}
if !is_highlight_color(highlight) {
raise BatchError(
"\{at}.highlight '\{highlight}' is not an ST_HighlightColor name (yellow, green, cyan, red, ...)",
)
}
Some(highlight)
}
None => None
}
RunSpec(
@document.run_properties(
is_bold=boolean_flag(spec, "bold", at),
is_italic=boolean_flag(spec, "italic", at),
is_underline=boolean_flag(spec, "underline", at),
is_strikethrough=boolean_flag(spec, "strike", at),
is_all_caps=boolean_flag(spec, "all_caps", at),
is_small_caps=boolean_flag(spec, "small_caps", at),
vertical_alignment=vertical,
font=checked_attribute(optional_string(spec, "font", at), at + ".font"),
font_size=size,
highlight~,
),
text,
)
}
///|
fn content_type_for_path(path : String, at : String) -> String raise BatchError {
if path.has_suffix(".png") {
"image/png"
} else if path.has_suffix(".jpg") || path.has_suffix(".jpeg") {
"image/jpeg"
} else if path.has_suffix(".gif") {
"image/gif"
} else {
raise BatchError(
"\{at}.image: cannot infer the content type from '\{path}'; set content_type",
)
}
}
///|
fn parse_table(
params : Map[String, Json],
at : String,
image_paths : Array[String],
seen_paths : StableStringSet,
notes? : NoteCollector? = None,
) -> OpSpec raise BatchError {
check_keys(params, ["rows", "header_rows"], at + ".params")
guard params.get("rows") is Some(Array(rows_json)) else {
raise BatchError("\{at}.params.rows must be an array of arrays")
}
if rows_json.length() == 0 {
raise BatchError("\{at}.params.rows must not be empty")
}
let header_rows = int_value(
params,
"header_rows",
at + ".params",
min=0,
max=rows_json.length(),
default=0,
)
let rows : Array[Array[CellSpec]] = []
for row_index, row_json in rows_json {
guard row_json is Array(cells_json) else {
raise BatchError("\{at}.params.rows[\{row_index}] must be an array")
}
if cells_json.length() == 0 {
raise BatchError(
"\{at}.params.rows[\{row_index}] must have at least one cell",
)
}
let cells : Array[CellSpec] = []
for cell_index, cell_json in cells_json {
let cell_where = "\{at}.params.rows[\{row_index}][\{cell_index}]"
guard cell_json is Object(cell) else {
raise BatchError("\{cell_where} must be an object")
}
check_keys(
cell,
["text", "paragraphs", "col_span", "row_span"],
cell_where,
)
let col_span = int_value(
cell,
"col_span",
cell_where,
min=1,
max=MAX_GRID_COLUMNS,
default=1,
)
// The writer requires a span to fit the remaining rows; mirroring
// that bound here keeps the ops[i] address on the error.
let row_span = int_value(
cell,
"row_span",
cell_where,
min=1,
max=rows_json.length() - row_index,
default=1,
)
let paragraphs : Array[ParagraphSpec] = []
match (cell.get("text"), cell.get("paragraphs")) {
(Some(String(text)), None) => {
check_text(text, cell_where + ".text")
paragraphs.push({
style: None,
align: None,
list: None,
inlines: [RunSpec(@document.run_properties(), text)],
})
}
(Some(_), None) =>
raise BatchError("\{cell_where}.text must be a string")
(None, Some(Array(paragraphs_json))) => {
if paragraphs_json.length() == 0 {
raise BatchError(
"\{cell_where}.paragraphs must not be empty (use \"text\": \"\" for a blank cell)",
)
}
for paragraph_index, paragraph_json in paragraphs_json {
guard paragraph_json is Object(paragraph) else {
raise BatchError(
"\{cell_where}.paragraphs[\{paragraph_index}] must be an object",
)
}
paragraphs.push(
parse_paragraph(
paragraph,
"\{cell_where}.paragraphs[\{paragraph_index}]",
image_paths,
seen_paths,
notes~,
),
)
}
}
(None, Some(_)) =>
raise BatchError("\{cell_where}.paragraphs must be an array")
(Some(_), Some(_)) =>
raise BatchError(
"\{cell_where}: text and paragraphs are mutually exclusive",
)
(None, None) =>
raise BatchError(
"\{cell_where} needs text or paragraphs (use \"text\": \"\" for a blank cell)",
)
}
cells.push({ paragraphs, col_span, row_span })
}
rows.push(cells)
}
TableOp(rows~, header_rows~)
}
///|
fn optional_string(
object : Map[String, Json],
key : String,
at : String,
) -> String? raise BatchError {
match object.get(key) {
Some(String(value)) => Some(value)
None => None
_ => raise BatchError("\{at}.\{key} must be a string")
}
}
///|
/// An exact integer in [min, max]. Fractional, non-finite, and
/// out-of-range numbers fail — `2.9` must never silently become `2`.
fn int_value(
object : Map[String, Json],
key : String,
at : String,
min~ : Int,
max~ : Int,
default~ : Int,
) -> Int raise BatchError {
match object.get(key) {
Some(Number(value, ..)) => {
let as_int = value.to_int()
if value != value || as_int.to_double() != value {
raise BatchError("\{at}.\{key} must be an integer (got \{value})")
}
if as_int < min || as_int > max {
raise BatchError("\{at}.\{key} must be between \{min} and \{max}")
}
as_int
}
None => default
_ => raise BatchError("\{at}.\{key} must be a number")
}
}
///|
/// Run/paragraph text: rejects raw line breaks and the other C0 controls
/// (tab excepted) that the writer fails closed on, so the error carries
/// the script address instead of a whole-document writer message.
fn check_text(text : String, at : String) -> Unit raise BatchError {
for unit in text {
let code = unit.to_int()
if code < 0x20 && unit != '\t' {
raise BatchError(
"\{at} contains an unserializable control character (code \{code}); text cannot hold raw line breaks",
)
}
// 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 BatchError(
"\{at} contains a character XML cannot represent (code \{code}: unpaired surrogate or U+FFFE/U+FFFF)",
)
}
}
}
///|
/// Attribute-destined strings (href, anchor, target_frame, font, alt):
/// must be non-empty and free of ALL C0 controls (XML attribute-value
/// normalization mutates even tab/CR/LF).
fn checked_attribute(value : String?, at : String) -> String? raise BatchError {
match value {
Some(text) => {
if text == "" {
raise BatchError("\{at} must not be empty")
}
for unit in text {
let code = unit.to_int()
if code < 0x20 {
raise BatchError(
"\{at} contains a control character that cannot survive XML attribute normalization",
)
}
if (code >= 0xD800 && code <= 0xDFFF) ||
code == 0xFFFE ||
code == 0xFFFF {
raise BatchError(
"\{at} contains a character XML cannot represent (code \{code}: unpaired surrogate or U+FFFE/U+FFFF)",
)
}
}
Some(text)
}
None => None
}
}
///|
priv struct ScanFrame {
is_object : Bool
keys : StableStringSet
mut expecting_key : Bool
// Last decoded key of this object (addresses child containers/values).
mut pending_key : String
// Element index for array frames (addresses child containers).
mut index : Int
// How this frame is addressed from its parent: a key, "[i]", or "" (root).
entry : String
}
///|
/// Renders the innermost frame's JSON path plus an optional leaf key —
/// "ops[3].params", "ops[3].params.rows[0][1].col_span".
fn render_scan_path(frames : Array[ScanFrame], leaf : String?) -> String {
let builder = StringBuilder::new()
for frame in frames {
if frame.entry == "" {
continue
}
if !frame.entry.has_prefix("[") && !builder.is_empty() {
builder.write_string(".")
}
builder.write_string(frame.entry)
}
match leaf {
Some(key) => {
if !builder.is_empty() {
builder.write_string(".")
}
builder.write_string(key)
}
None => ()
}
if builder.is_empty() {
"the script"
} else {
builder.to_string()
}
}
///|
/// Renders a UTF-16 code unit as a readable \uXXXX escape (for lone
/// surrogates in diagnostics).
fn render_unit_escape(code : Int) -> String {
let digits = "0123456789ABCDEF"
let builder = StringBuilder::new()
builder.write_string("\\u")
for shift in [12, 8, 4, 0] {
let nibble = (code >> shift) & 0xF
match digits.get_char(nibble) {
Some(ch) => builder.write_char(ch)
None => ()
}
}
builder.to_string()
}
///|
/// Lexes the JSON string starting AFTER its opening quote. Returns the
/// value in two forms — `canonical`, a lossless encoding of the decoded
/// UTF-16 code-unit sequence (duplicate comparison sees exactly what
/// `@json.parse` would produce: distinct lone surrogates stay distinct,
/// and raw vs escaped spellings of the same character coincide with no
/// pairing logic at all) — and `display`, a human-readable rendering
/// with lone surrogates shown as \uXXXX. The third result is the index
/// just past the closing quote (None when unterminated — @json.parse's
/// error to report).
fn lex_json_string(
units : ArrayView[UInt16],
start : Int,
) -> (String, String, Int?) {
let decoded : Array[Int] = []
let length = units.length()
let mut cursor = start
fn hex4(at : Int) -> Int? {
if at + 4 > length {
return None
}
let mut value = 0
for offset in 0..<4 {
let c = units[at + offset].to_int()
let digit = match c {
'0'..='9' => c - '0'.to_int()
'a'..='f' => c - 'a'.to_int() + 10
'A'..='F' => c - 'A'.to_int() + 10
_ => return None
}
value = value * 16 + digit
}
Some(value)
}
let mut closed_after : Int? = None
while cursor < length {
let unit = units[cursor].to_int()
if unit == '"'.to_int() {
closed_after = Some(cursor + 1)
break
}
if unit == '\\'.to_int() {
if cursor + 1 >= length {
return ("", "", None)
}
let escape = units[cursor + 1].to_int()
match escape {
'u' =>
match hex4(cursor + 2) {
Some(code) => {
decoded.push(code)
cursor += 6
}
None => {
// Malformed \u escape: @json.parse reports it; keep scanning.
decoded.push('u'.to_int())
cursor += 2
}
}
_ => {
let code = match escape {
'n' => '\n'.to_int()
't' => '\t'.to_int()
'r' => '\r'.to_int()
'b' => 0x08
'f' => 0x0C
'"' => '"'.to_int()
'\\' => '\\'.to_int()
'/' => '/'.to_int()
// Invalid escape: @json.parse reports it; keep scanning.
_ => '?'.to_int()
}
decoded.push(code)
cursor += 2
}
}
continue
}
decoded.push(unit)
cursor += 1
}
match closed_after {
None => ("", "", None)
Some(next) => {
let canonical = StringBuilder::new()
let display = StringBuilder::new()
let mut at = 0
while at < decoded.length() {
let code = decoded[at]
canonical.write_string("\{code};")
if code >= 0xD800 && code <= 0xDBFF && at + 1 < decoded.length() {
let low = decoded[at + 1]
if low >= 0xDC00 && low <= 0xDFFF {
canonical.write_string("\{low};")
match
(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00)).to_char() {
Some(ch) => display.write_char(ch)
None => ()
}
at += 2
continue
}
}
match code.to_char() {
Some(ch) => display.write_char(ch)
None => display.write_string(render_unit_escape(code))
}
at += 1
}
(canonical.to_string(), display.to_string(), Some(next))
}
}
}
///|
/// Strict lexical validation of the raw script text, run BEFORE
/// `@json.parse` (the CLI always does; programmatic `parse_batch(Json)`
/// callers holding original text should too). Two rules the parsed `Json`
/// tree cannot express:
///
/// - **Duplicate keys** are refused, addressed with the object's path.
/// `@json.parse` is last-wins per ECMA-404, which would let
/// `{"op": "table", "op": "paragraph"}` silently drop half an op. Keys
/// compare DECODED, so `"op"` duplicates `"op"`.
/// - **Numbers must be plain decimal integers.** Every numeric field in
/// docx.batch/1 is an integer, and a `Json` Double cannot distinguish
/// `1` from `0.999999999999999999999999` — fractions and exponents are
/// rejected at the lexeme, before precision is lost.
///
/// Structure is tracked per `{`/`[` frame; malformed JSON is left for
/// `@json.parse` to diagnose.
pub fn check_script_text(text : String) -> Unit raise BatchError {
let units = text.code_units()
let length = units.length()
let frames : Array[ScanFrame] = []
let mut index = 0
fn frame_entry() -> String {
match frames.last() {
Some(parent) =>
if parent.is_object {
parent.pending_key
} else {
"[\{parent.index}]"
}
None => ""
}
}
while index < length {
let unit = units[index].to_int()
match unit {
'{' | '[' => {
frames.push({
is_object: unit is '{',
keys: SortedSet([]),
expecting_key: unit is '{',
pending_key: "",
index: 0,
entry: frame_entry(),
})
index += 1
}
'}' | ']' => {
let _ = frames.pop()
index += 1
}
',' => {
match frames.last() {
Some(frame) =>
if frame.is_object {
frame.expecting_key = true
} else {
frame.index += 1
}
None => ()
}
index += 1
}
':' => {
match frames.last() {
Some(frame) if frame.is_object => frame.expecting_key = false
_ => ()
}
index += 1
}
'"' => {
let (canonical, display, next) = lex_json_string(units, index + 1)
match next {
Some(next_index) => {
match frames.last() {
Some(frame) if frame.is_object && frame.expecting_key => {
if frame.keys.contains(canonical) {
raise BatchError(
"\{render_scan_path(frames, None)} repeats the key \"\{display}\" (strict scripts must not rely on last-wins parsing)",
)
}
frame.keys.add(canonical)
frame.pending_key = display
}
_ => ()
}
index = next_index
}
None => return
}
}
'-' | '0'..='9' => {
let start = index
let mut cursor = index
let mut plain_integer = true
while cursor < length {
match units[cursor].to_int() {
'.' | 'e' | 'E' => {
plain_integer = false
cursor += 1
}
'-' | '+' | '0'..='9' => cursor += 1
_ => break
}
}
if !plain_integer {
let lexeme_builder = StringBuilder::new()
for at in start.. lexeme_builder.write_char(ch)
None => ()
}
}
let leaf = match frames.last() {
Some(frame) if frame.is_object => Some(frame.pending_key)
_ => None
}
raise BatchError(
"\{render_scan_path(frames, leaf)} must be a plain decimal integer — docx batch scripts have no fractional fields (got '\{lexeme_builder.to_string()}')",
)
}
index = cursor
}
_ => index += 1
}
}
}
///|
fn boolean_flag(
object : Map[String, Json],
key : String,
at : String,
) -> Bool raise BatchError {
match object.get(key) {
Some(True) => true
Some(False) => false
None => false
_ => raise BatchError("\{at}.\{key} must be a boolean")
}
}
///|
/// Builds writer input from the parsed script and the loaded image bytes
/// (keyed by the exact paths `image_paths` returned).
pub fn BatchScript::build_body(
self : BatchScript,
images : Map[String, Bytes],
) -> Array[@document.DocumentElement] raise BatchError {
let body : Array[@document.DocumentElement] = []
for op in self.ops {
match op {
ParagraphOp(spec) => body.push(build_paragraph(spec, images))
TableOp(rows~, header_rows~) => {
let row_elements : Array[@document.DocumentElement] = []
for row_index, cells in rows {
let cell_elements : Array[@document.DocumentElement] = []
for cell in cells {
let cell_paragraphs : Array[@document.DocumentElement] = []
for paragraph in cell.paragraphs {
cell_paragraphs.push(build_paragraph(paragraph, images))
}
cell_elements.push(
@document.table_cell(
cell_paragraphs,
col_span=cell.col_span,
row_span=cell.row_span,
),
)
}
row_elements.push(
@document.table_row(
cell_elements,
is_header=row_index < header_rows,
),
)
}
body.push(@document.table(row_elements))
}
}
}
body
}
///|
/// One built comment, ready for the writer: metadata verbatim, anchors
/// as 0-based body BLOCK indexes (comment ops produce no blocks, so
/// these are NOT op indexes), and the built plain-content body. `at()`
/// is the op address ("ops[4]") so callers can attribute writer-side
/// failures — the date's lexical xsd:dateTime check happens there — to
/// the exact script op.
pub struct BatchComment {
priv at : String
priv author : String
priv initials : String?
priv date : String?
priv anchor : CommentAnchor
priv done : Bool?
priv body : Array[@document.DocumentElement]
}
///|
/// The op address ("ops[4]") for error attribution.
pub fn BatchComment::at(self : BatchComment) -> String {
self.at
}
///|
/// The comment author, verbatim.
pub fn BatchComment::author(self : BatchComment) -> String {
self.author
}
///|
/// The author initials, when given.
pub fn BatchComment::initials(self : BatchComment) -> String? {
self.initials
}
///|
/// The lexical timestamp, when given (validated by the writer).
pub fn BatchComment::date(self : BatchComment) -> String? {
self.date
}
///|
/// First anchored body block (0-based block index, not op index), or
/// None for a reply (replies are anchorless).
pub fn BatchComment::from_block(self : BatchComment) -> Int? {
match self.anchor {
AnchorBlocks(from~, ..) => Some(from)
ReplyToComment(_) => None
}
}
///|
/// Last anchored body block (inclusive), or None for a reply.
pub fn BatchComment::to_block(self : BatchComment) -> Int? {
match self.anchor {
AnchorBlocks(to~, ..) => Some(to)
ReplyToComment(_) => None
}
}
///|
/// The dense comment index (= writer id) this reply answers, or None
/// for an anchored comment.
pub fn BatchComment::reply_to(self : BatchComment) -> Int? {
match self.anchor {
ReplyToComment(parent) => Some(parent)
AnchorBlocks(..) => None
}
}
///|
/// The resolution flag, when the op set one.
pub fn BatchComment::done(self : BatchComment) -> Bool? {
self.done
}
///|
/// The built plain-content body paragraphs (a fresh copy).
pub fn BatchComment::body(
self : BatchComment,
) -> Array[@document.DocumentElement] {
self.body.copy()
}
///|
/// One built note body plus its script address for error attribution.
pub struct BatchNote {
priv at : String
priv body : Array[@document.DocumentElement]
}
///|
/// The script address ("ops[2].params.runs[1].footnote") of this note.
pub fn BatchNote::at(self : BatchNote) -> String {
self.at
}
///|
/// The built plain-content body paragraphs (a fresh copy).
pub fn BatchNote::body(self : BatchNote) -> Array[@document.DocumentElement] {
self.body.copy()
}
///|
/// Number of footnotes and endnotes in the script.
pub fn BatchScript::note_counts(self : BatchScript) -> (Int, Int) {
(self.footnotes.length(), self.endnotes.length())
}
///|
/// Builds the writer-facing footnote bodies, in first-reference order
/// (the dense per-kind index the body's note references name).
pub fn BatchScript::build_footnotes(
self : BatchScript,
) -> Array[BatchNote] raise BatchError {
build_note_bodies(self.footnotes)
}
///|
/// Builds the writer-facing endnote bodies (see `build_footnotes`).
pub fn BatchScript::build_endnotes(
self : BatchScript,
) -> Array[BatchNote] raise BatchError {
build_note_bodies(self.endnotes)
}
///|
fn build_note_bodies(
specs : Array[(String, Array[ParagraphSpec])],
) -> Array[BatchNote] raise BatchError {
let built : Array[BatchNote] = []
let no_images : Map[String, Bytes] = Map([])
for spec in specs {
let (at, paragraphs) = spec
let body : Array[@document.DocumentElement] = []
for paragraph in paragraphs {
body.push(build_paragraph(paragraph, no_images))
}
built.push({ at, body })
}
built
}
///|
/// Builds the writer-facing comment list, in op order (which is also
/// the dense-id order the writer assigns). Comment bodies are plain
/// content, so unlike `build_body` no image bytes are needed.
pub fn BatchScript::build_comments(
self : BatchScript,
) -> Array[BatchComment] raise BatchError {
let built : Array[BatchComment] = []
let no_images : Map[String, Bytes] = Map([])
for comment in self.comments {
let body : Array[@document.DocumentElement] = []
for spec in comment.body {
body.push(build_paragraph(spec, no_images))
}
built.push({
at: comment.at,
author: comment.author,
initials: comment.initials,
date: comment.date,
anchor: comment.anchor,
done: comment.done,
body,
})
}
built
}
///|
fn build_paragraph(
spec : ParagraphSpec,
images : Map[String, Bytes],
) -> @document.DocumentElement raise BatchError {
let inlines : Array[@document.DocumentElement] = []
for inline in spec.inlines {
inlines.push(build_inline(inline, images))
}
@document.paragraph(
inlines,
properties=@document.paragraph_properties(
style_id=spec.style,
alignment=spec.align,
numbering=spec.list,
),
)
}
///|
fn build_inline(
spec : InlineSpec,
images : Map[String, Bytes],
) -> @document.DocumentElement raise BatchError {
match spec {
RunSpec(properties, text) =>
@document.run([@document.text(text)], properties~)
LinkSpec(href~, anchor~, target_frame~, runs~) => {
let children : Array[@document.DocumentElement] = []
for inner in runs {
children.push(build_inline(inner, images))
}
@document.hyperlink(children, href~, anchor~, target_frame~)
}
ImageSpec(path~, content_type~, alt~, at~) =>
match images.get(path) {
Some(data) =>
@document.run([@document.image(content_type, data, alt_text=alt)])
None =>
raise BatchError(
"\{at}.path: image bytes for '\{path}' were not provided",
)
}
NoteRefSpec(note_type~, index~) =>
@document.run([@document.note_reference(note_type, index.to_string())])
}
}
///|
/// The consumed schema behind `docx annotate add`: one comment for an
/// existing document. Same STRICT discipline as the batch scripts —
/// run `check_script_text` on the raw text first (duplicate keys,
/// integer lexemes), then this parser rejects unknown keys and wrong
/// types with addressed errors.
pub const SCHEMA_ANNOTATE : String = "docx.annotate/1"
///|
/// A parsed `docx.annotate/1` envelope: WHO plus the plain-content
/// body. Anchors are CLI flags (`--at`/`--to`), never envelope keys.
pub struct AnnotateEnvelope {
priv author : String
priv initials : String?
priv date : String?
priv body : Array[ParagraphSpec]
}
///|
/// The comment author, verbatim.
pub fn AnnotateEnvelope::author(self : AnnotateEnvelope) -> String {
self.author
}
///|
/// The author initials, when given.
pub fn AnnotateEnvelope::initials(self : AnnotateEnvelope) -> String? {
self.initials
}
///|
/// The lexical timestamp, when given (validated by the writer layer).
pub fn AnnotateEnvelope::date(self : AnnotateEnvelope) -> String? {
self.date
}
///|
/// Builds the plain-content body paragraphs.
pub fn AnnotateEnvelope::build_body(
self : AnnotateEnvelope,
) -> Array[@document.DocumentElement] raise BatchError {
let no_images : Map[String, Bytes] = Map([])
let body : Array[@document.DocumentElement] = []
for paragraph in self.body {
body.push(build_paragraph(paragraph, no_images))
}
body
}
///|
/// Parses and strictly validates a `docx.annotate/1` envelope.
pub fn parse_annotate(json : Json) -> AnnotateEnvelope raise BatchError {
guard json is Object(root) else {
raise BatchError("the envelope must be a JSON object")
}
check_keys(root, ["schema", "comment"], "the envelope")
guard root.get("schema") is Some(String(schema)) else {
raise BatchError("the envelope needs \"schema\": \"\{SCHEMA_ANNOTATE}\"")
}
if schema != SCHEMA_ANNOTATE {
raise BatchError(
"unsupported schema '\{schema}' (this build accepts \{SCHEMA_ANNOTATE})",
)
}
guard root.get("comment") is Some(Object(comment)) else {
raise BatchError("the envelope needs \"comment\": {...}")
}
check_keys(
comment,
["author", "initials", "date", "paragraphs"],
"the envelope's comment",
)
guard comment.get("author") is Some(author_json) else {
raise BatchError("the envelope's comment needs \"author\"")
}
guard author_json is String(author) else {
raise BatchError("the envelope's comment.author must be a string")
}
if author == "" {
raise BatchError("the envelope's comment.author must not be empty")
}
let _ = checked_attribute(Some(author), "the envelope's comment.author")
let initials = checked_attribute(
optional_string(comment, "initials", "the envelope's comment"),
"the envelope's comment.initials",
)
let date = optional_string(comment, "date", "the envelope's comment")
guard comment.get("paragraphs") is Some(Array(paragraphs_json)) else {
raise BatchError(
"the envelope's comment needs \"paragraphs\": [...] (the same plain-content grammar as docx.batch/2 comment bodies)",
)
}
if paragraphs_json.length() == 0 {
raise BatchError("the envelope's comment.paragraphs must not be empty")
}
let image_paths : Array[String] = []
let seen_paths : StableStringSet = SortedSet([])
let body : Array[ParagraphSpec] = []
for index, paragraph_json in paragraphs_json {
guard paragraph_json is Object(paragraph) else {
raise BatchError(
"the envelope's comment.paragraphs[\{index}] must be an object",
)
}
body.push(
parse_paragraph(
paragraph,
"the envelope's comment.paragraphs[\{index}]",
image_paths,
seen_paths,
plain=true,
),
)
}
{ author, initials, date, body }
}