///|
fn bounded_decimal_argument(
matches : @argparse.Matches,
name : String,
default_value : Int,
minimum : Int,
maximum : Int,
) -> Int raise CliFailure {
let value = match optional_value(matches, name) {
Some(text) => text
None => return default_value
}
if value == "" || value.length() > 10 {
raise docx_cli_failure(
"office.invalid_arguments",
"--\{name} must be an integer between \{minimum} and \{maximum}",
details=Json::object({
"argument": Json::string(name),
"minimum": Json::number(minimum.to_double()),
"maximum": Json::number(maximum.to_double()),
}),
)
}
let mut result = 0
for unit in value {
if !(unit is ('0'..='9')) {
raise docx_cli_failure(
"office.invalid_arguments",
"--\{name} must be an integer between \{minimum} and \{maximum}",
details=Json::object({
"argument": Json::string(name),
"minimum": Json::number(minimum.to_double()),
"maximum": Json::number(maximum.to_double()),
}),
)
}
let digit = unit.to_int() - '0'.to_int()
if result > (maximum - digit) / 10 {
raise docx_cli_failure(
"office.invalid_arguments",
"--\{name} must be an integer between \{minimum} and \{maximum}",
details=Json::object({
"argument": Json::string(name),
"minimum": Json::number(minimum.to_double()),
"maximum": Json::number(maximum.to_double()),
}),
)
}
result = result * 10 + digit
}
if result < minimum || result > maximum {
raise docx_cli_failure(
"office.invalid_arguments",
"--\{name} must be an integer between \{minimum} and \{maximum}",
details=Json::object({
"argument": Json::string(name),
"minimum": Json::number(minimum.to_double()),
"maximum": Json::number(maximum.to_double()),
}),
)
}
result
}
///|
fn cli_max_elements(matches : @argparse.Matches) -> Int raise CliFailure {
bounded_decimal_argument(
matches, "max-elements", docx_cli_default_max_elements, 1, docx_cli_hard_max_elements,
)
}
///|
/// Applies the tighter XLSX scan ceiling before opening or parsing the input
/// package. Content-based format validation still happens after the bounded
/// read, but an `.xlsx` path must never make an oversized request pay that
/// cost first. DOCX retains its independently documented 200,000-node limit.
fn validate_xlsx_max_elements_before_read(
file : String,
max_elements : Int,
) -> Unit raise CliFailure {
if file.to_lower().has_suffix(".xlsx") {
validate_xlsx_max_elements(max_elements)
}
}
///|
fn cli_max_output_chars(matches : @argparse.Matches) -> Int raise CliFailure {
bounded_decimal_argument(
matches, "max-output-chars", docx_cli_default_max_output_chars, 1, docx_cli_hard_max_output_chars,
)
}
///|
fn docx_common_options() -> Array[@argparse.OptionArg] {
[
OptionArg(
"max-elements",
long="max-elements",
about="maximum DOCX projection nodes or XLSX cells to scan (default 50000; XLSX hard ceiling 100000)",
),
OptionArg(
"max-output-chars",
long="max-output-chars",
about="maximum successful stdout characters, including trailing LF (default 1048576, hard limit 4194304)",
),
]
}
///|
fn docx_json_flag() -> @argparse.FlagArg {
FlagArg("json", long="json", about="emit office.output/1 JSON")
}
///|
fn emit_docx_success(output : String) -> Unit {
println(output)
}
///|
async fn resolve_optional_under(
projection : DocxProjection,
matches : @argparse.Matches,
) -> (DocxProjectionEntry?, String?) {
match optional_value(matches, "under") {
Some(path) => {
let entry = resolve_projection_selector(projection, path)
(Some(entry), Some(entry.path))
}
None => (None, None)
}
}
///|
fn resolve_optional_xlsx_under(
projection : XlsxProjection,
matches : @argparse.Matches,
) -> XlsxResolvedSelector? raise CliFailure {
match optional_value(matches, "under") {
Some(path) => Some(resolve_xlsx_selector(projection, path))
None => None
}
}
///|
async fn run_docx_outline(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let max_elements = cli_max_elements(matches)
validate_xlsx_max_elements_before_read(file, max_elements)
let max_output = cli_max_output_chars(matches)
let source = read_office_package(file, cancelled=() => {
@async.is_being_cancelled()
})
match source.format {
Docx => {
let projection = open_docx_projection_archive(
file,
source.archive,
max_elements,
cancelled=office_async_cancelled,
)
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_docx_json_output(
docx_outline_payload(projection, max_output_chars=max_output),
),
)
} else {
emit_docx_success(
checked_docx_human_output(
docx_outline_human(projection, maximum=max_output),
max_output,
),
)
}
}
Xlsx => {
let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
@async.is_being_cancelled()
})
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_office_json_output(
xlsx_outline_payload(projection, max_output),
),
)
} else {
emit_docx_success(
checked_office_human_output(
xlsx_outline_human(projection, max_output),
max_output,
"xlsx",
),
)
}
}
}
}
///|
async fn run_docx_get(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let selector = required_value(matches, "selector")
let max_elements = cli_max_elements(matches)
validate_xlsx_max_elements_before_read(file, max_elements)
let max_output = cli_max_output_chars(matches)
let source = read_office_package(file, cancelled=() => {
@async.is_being_cancelled()
})
match source.format {
Docx => {
let projection = open_docx_projection_archive(
file,
source.archive,
max_elements,
cancelled=office_async_cancelled,
)
let entry = resolve_projection_selector(projection, selector)
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_docx_json_output(
get_docx_payload(projection, entry, max_output),
),
)
} else {
let text = match entry.element {
Some(_) => {
let available = (max_output - docx_cli_output_framing_chars).max(0)
let budget = DocxTextBudget::new_reported(
"successful command output characters", available, max_output, docx_cli_output_framing_chars,
)
entry_text_with_budget_cooperative(
entry,
budget,
cancelled=projection.cancelled,
)
}
None => ""
}
emit_docx_success(
checked_docx_human_output(
docx_get_human(entry, text, projection.warnings, maximum=max_output),
max_output,
),
)
}
}
Xlsx => {
let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
@async.is_being_cancelled()
})
let resolved = resolve_xlsx_selector(projection, selector)
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_office_json_output(
xlsx_get_payload(projection, resolved, max_output),
),
)
} else {
emit_docx_success(
checked_office_human_output(
xlsx_get_human(projection, resolved, max_output),
max_output,
"xlsx",
),
)
}
}
}
}
///|
async fn run_docx_text(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let max_elements = cli_max_elements(matches)
validate_xlsx_max_elements_before_read(file, max_elements)
let max_output = cli_max_output_chars(matches)
let offset = bounded_decimal_argument(
matches, "offset", 0, 0, docx_cli_hard_max_elements,
)
let limit = bounded_decimal_argument(
matches, "limit", docx_cli_default_text_limit, 0, docx_cli_hard_text_limit,
)
let source = read_office_package(file, cancelled=() => {
@async.is_being_cancelled()
})
match source.format {
Docx => {
let projection = open_docx_projection_archive(
file,
source.archive,
max_elements,
cancelled=office_async_cancelled,
)
let (under, under_path) = resolve_optional_under(projection, matches)
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_docx_json_output(
docx_text_payload(
projection, under, under_path, offset, limit, max_output,
),
),
)
} else {
emit_docx_success(
checked_docx_human_output(
docx_text_human(
projection,
under,
offset,
limit,
projection.warnings,
maximum=max_output,
),
max_output,
),
)
}
}
Xlsx => {
let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
@async.is_being_cancelled()
})
let under = resolve_optional_xlsx_under(projection, matches)
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_office_json_output(
xlsx_text_payload(projection, under, offset, limit, max_output),
),
)
} else {
emit_docx_success(
checked_office_human_output(
xlsx_text_human(projection, under, offset, limit, max_output),
max_output,
"xlsx",
),
)
}
}
}
}
///|
fn canonical_query_kind(input : String) -> String? {
match input.to_lower() {
"body" => Some("body")
"header" => Some("header")
"footer" => Some("footer")
"footnotes" => Some("footnotes")
"endnotes" => Some("endnotes")
"comments" => Some("comments")
"note" => Some("note")
"comment" => Some("comment")
"p" | "paragraph" => Some("p")
"r" | "run" => Some("r")
"tbl" | "table" => Some("tbl")
"tr" | "row" => Some("tr")
"tc" | "cell" => Some("tc")
"hyperlink" | "link" => Some("hyperlink")
"image" | "picture" => Some("image")
_ => None
}
}
///|
fn query_kind(matches : @argparse.Matches) -> String? raise CliFailure {
match optional_value(matches, "kind") {
Some(value) =>
match canonical_query_kind(value) {
Some(kind) => Some(kind)
None =>
raise docx_cli_failure(
"office.docx.invalid_query_kind",
"unknown DOCX query kind '\{bounded_text(value, 80)}'",
details=Json::object({
"kind": Json::string(bounded_text(value, 80)),
"allowed": Json::array([
Json::string("body"),
Json::string("header"),
Json::string("footer"),
Json::string("footnotes"),
Json::string("endnotes"),
Json::string("comments"),
Json::string("note"),
Json::string("comment"),
Json::string("p"),
Json::string("r"),
Json::string("tbl"),
Json::string("tr"),
Json::string("tc"),
Json::string("hyperlink"),
Json::string("image"),
]),
}),
)
}
None => None
}
}
///|
fn canonical_query_property_name(input : String) -> String? {
match input.to_lower() {
"style_id" | "style-id" => Some("style_id")
"style_name" | "style-name" => Some("style_name")
"alignment" | "align" => Some("alignment")
"bold" => Some("bold")
"italic" => Some("italic")
"underline" => Some("underline")
"content_type" | "content-type" => Some("content_type")
"href" | "url" => Some("href")
"author" => Some("author")
"done" | "resolved" => Some("done")
_ => None
}
}
///|
fn validate_query_value(
value : String,
argument : String,
allow_empty : Bool,
) -> Unit raise CliFailure {
let maximum = docx_cli_max_xml_token_chars
if (!allow_empty && value == "") || !has_at_most_chars(value, maximum) {
let requirement = if allow_empty { "a value" } else { "a non-empty value" }
raise docx_cli_failure(
"office.invalid_arguments",
"--\{argument} must be \{requirement} of at most \{maximum} characters",
details=Json::object({
"argument": Json::string(argument),
"maximum_characters": Json::number(maximum.to_double()),
}),
)
}
}
///|
fn query_properties(
matches : @argparse.Matches,
) -> Array[DocxPropertyPredicate] raise CliFailure {
let raw = repeated_values(matches, "property")
if raw.length() > 16 {
raise docx_cli_failure(
"office.docx.query_predicate_limit",
"at most 16 --property predicates are allowed",
details=Json::object({
"limit": Json::number(16),
"actual": Json::number(raw.length().to_double()),
}),
)
}
let predicates : Array[DocxPropertyPredicate] = []
for value in raw {
guard value.find("=") is Some(split) && split > 0 else {
raise docx_cli_failure(
"office.invalid_arguments",
"--property requires NAME=VALUE",
details=Json::object({
"argument": Json::string("property"),
"value": Json::string(bounded_text(value, 160)),
}),
)
}
let raw_name = value[:split].to_owned()
let property_value = value[split + 1:].to_owned()
guard canonical_query_property_name(raw_name) is Some(name) else {
raise docx_cli_failure(
"office.docx.invalid_query_property",
"unknown DOCX query property '\{bounded_text(raw_name, 80)}'",
details=Json::object({
"property": Json::string(bounded_text(raw_name, 80)),
}),
)
}
validate_query_value(property_value, "property", true)
if name == "bold" ||
name == "italic" ||
name == "underline" ||
name == "done" {
if property_value != "true" && property_value != "false" {
raise docx_cli_failure(
"office.docx.invalid_query_property",
"boolean query property '\{name}' requires true or false",
details=Json::object({
"property": Json::string(name),
"value": Json::string(bounded_text(property_value, 80)),
}),
)
}
}
predicates.push({ name, value: property_value, })
}
predicates
}
///|
fn reject_xlsx_query_options(
matches : @argparse.Matches,
) -> Unit raise CliFailure {
let incompatible : Array[String] = []
for name in ["kind", "text", "id"] {
if optional_value(matches, name) is Some(_) {
incompatible.push("--" + name)
}
}
if !repeated_values(matches, "property").is_empty() {
incompatible.push("--property")
}
if matches.flags.get_or_default("ignore-case", false) {
incompatible.push("--ignore-case")
}
if !incompatible.is_empty() {
raise xlsx_cli_failure(
"office.xlsx.unsupported_query_options",
"DOCX query options cannot be used with an XLSX workbook; use the cell selector predicates",
details=Json::object({
"options": Json::array(incompatible.map(Json::string)),
}),
)
}
}
///|
fn reject_docx_content_selector(
matches : @argparse.Matches,
) -> Unit raise CliFailure {
match optional_value(matches, "selector") {
Some(value) =>
raise docx_cli_failure(
"office.docx.unsupported_query_selector",
"the positional cell selector is available only for XLSX queries",
details=Json::object({
"selector": Json::string(bounded_text(value, 240)),
}),
)
None => ()
}
}
///|
async fn run_docx_query(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let max_elements = cli_max_elements(matches)
validate_xlsx_max_elements_before_read(file, max_elements)
let max_output = cli_max_output_chars(matches)
let offset = bounded_decimal_argument(
matches, "offset", 0, 0, docx_cli_hard_max_elements,
)
let limit = bounded_decimal_argument(
matches, "limit", docx_cli_default_query_limit, 0, docx_cli_hard_query_limit,
)
let source = read_office_package(file, cancelled=() => {
@async.is_being_cancelled()
})
match source.format {
Docx => {
reject_docx_content_selector(matches)
let text = optional_value(matches, "text")
match text {
Some(value) => validate_query_value(value, "text", false)
None => ()
}
let stable_id = optional_value(matches, "id")
match stable_id {
Some(value) => validate_query_value(value, "id", false)
None => ()
}
let projection = open_docx_projection_archive(
file,
source.archive,
max_elements,
cancelled=office_async_cancelled,
)
let (under, under_path) = resolve_optional_under(projection, matches)
let spec : DocxQuerySpec = {
under: under_path,
kind: query_kind(matches),
text,
stable_id,
properties: query_properties(matches),
ignore_case: matches.flags.get_or_default("ignore-case", false),
offset,
limit,
}
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_docx_json_output(
docx_query_payload(projection, under, spec, max_output),
),
)
} else {
emit_docx_success(
checked_docx_human_output(
docx_query_human(
projection,
under,
spec,
projection.warnings,
maximum=max_output,
),
max_output,
),
)
}
}
Xlsx => {
reject_xlsx_query_options(matches)
let selector = optional_value(matches, "selector").unwrap_or("cell")
let projection = open_xlsx_projection(source, max_elements, cancelled=() => {
@async.is_being_cancelled()
})
let under = resolve_optional_xlsx_under(projection, matches)
let spec : XlsxQuerySpec = {
selector,
predicates: parse_xlsx_query_selector(selector),
offset,
limit,
}
if matches.flags.get_or_default("json", false) {
emit_docx_success(
checked_office_json_output(
xlsx_query_payload(projection, under, spec, max_output),
),
)
} else {
emit_docx_success(
checked_office_human_output(
xlsx_query_human(projection, under, spec, max_output),
max_output,
"xlsx",
),
)
}
}
}
}
///|
fn outline_command() -> @argparse.Command {
Command(
"outline",
about="Summarize bounded XLSX or DOCX structure using canonical selectors",
positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())],
options=docx_common_options(),
flags=[docx_json_flag()],
)
}
///|
fn get_command() -> @argparse.Command {
Command(
"get",
about="Resolve one canonical XLSX or DOCX selector",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
PositionArg(
"selector",
about="canonical path such as /docx/body/p[1] or /xlsx/sheet[name=\"Data\"]/cell[A1]",
num_args=@argparse.ValueRange::single(),
),
],
options=docx_common_options(),
flags=[docx_json_flag()],
)
}
///|
fn text_command() -> @argparse.Command {
let options = docx_common_options()
options.append([
OptionArg(
"under",
long="under",
about="restrict results to a canonical DOCX subtree or XLSX sheet/range/cell",
),
OptionArg("offset", long="offset", about="zero-based result offset"),
OptionArg(
"limit",
long="limit",
about="maximum returned paragraphs or cells (default 2000, hard limit 10000)",
),
])
Command(
"text",
about="Extract bounded path-tagged XLSX cell or DOCX paragraph text",
positionals=[PositionArg("file", num_args=@argparse.ValueRange::single())],
options~,
flags=[docx_json_flag()],
)
}
///|
fn query_command() -> @argparse.Command {
let options = docx_common_options()
options.append([
OptionArg(
"under",
long="under",
about="restrict matches to a canonical DOCX subtree or XLSX sheet/range/cell",
),
OptionArg(
"kind",
long="kind",
about="DOCX only: exact element kind (aliases: paragraph, run, table, row, cell, link, picture)",
),
OptionArg(
"text",
long="text",
about="DOCX only: literal text substring (no regular expressions)",
),
OptionArg("id", long="id", about="DOCX only: exact annotation id"),
OptionArg(
"property",
long="property",
action=Append,
about="DOCX only: repeatable declared property predicate NAME=VALUE",
),
OptionArg("offset", long="offset", about="zero-based match offset"),
OptionArg(
"limit",
long="limit",
about="maximum returned matches (default 100, hard limit 1000)",
),
])
Command(
"query",
about="Run bounded deterministic predicates over XLSX cells or DOCX elements",
positionals=[
PositionArg("file", num_args=@argparse.ValueRange::single()),
PositionArg(
"selector",
about="XLSX only: cell[predicate] selector (default cell)",
num_args=ValueRange(lower=0, upper=1),
),
],
options~,
flags=[
docx_json_flag(),
FlagArg(
"ignore-case",
long="ignore-case",
about="compare --text with locale-independent Unicode simple case mapping",
),
],
)
}