///|
/// Ceiling on bytes for one embedded DOCX preview image.
let office_preview_max_image_bytes : Int = 4 * 1024 * 1024
///|
/// Ceiling on total embedded DOCX preview image bytes.
let office_preview_max_total_image_bytes : Int = 16 * 1024 * 1024
///|
/// Ceiling on the published preview document size.
let office_preview_max_output_bytes : Int = 32 * 1024 * 1024
///|
/// Row/column ceilings applied per XLSX sheet, mirrored into the report.
let office_preview_max_rows : Int = 1000
///|
let office_preview_max_cols : Int = 256
///|
/// Ceiling on converter warnings carried into the preview report.
let office_preview_max_warnings : Int = 64
///|
priv struct OfficePreviewStats {
charts_rendered : Int
charts_placeholder : Int
truncated_sheets : Array[String]
images_embedded : Int
images_omitted : Int
warnings : Array[String]
}
///|
/// Report and human-output strings derive from file content (sheet names,
/// converter messages) and platform argv; unpaired UTF-16 surrogates in
/// them must not trap stdout encoding after publication succeeded.
fn office_preview_clean_text(text : String) -> String {
let (clean, _) = @xlsx2html.sanitize_utf16_lossy(text)
clean
}
///|
fn office_preview_count_occurrences(text : String, needle : String) -> Int {
guard needle.length() > 0 else { return 0 }
let mut count = 0
let mut position = 0
while position < text.length() {
match text[position:].find(needle) {
Some(index) => {
count += 1
position += index + needle.length()
}
None => break
}
}
count
}
///|
/// Renders the XLSX preview: the shared bounded HTML renderer plus a
/// truthful truncation/chart report computed from the workbook itself.
fn office_preview_render_xlsx(
source : OfficeReadPackage,
) -> (String, OfficePreviewStats) raise {
let workbook = open_xlsx_read_package(
source,
cancelled=office_async_cancelled,
)
let html = @xlsx2html.render_workbook(
workbook,
title=source.file,
max_rows=office_preview_max_rows,
max_cols=office_preview_max_cols,
include_images=true,
) catch {
error =>
raise CliFailure(
@lib.protocol_error(
"office.xlsx.preview_failed",
"XLSX preview rendering failed: " + bounded_text("\{error}", 240),
details=Json::object({
"file": Json::string(bounded_text(source.file, 160)),
}),
),
)
}
let truncated_sheets : Array[String] = []
for name in workbook.get_sheet_list() {
match workbook.sheet(name) {
Some(worksheet) => {
let bounds = worksheet.used_bounds_limited(
maximum_stored_cells=office_read_max_parser_items,
cancelled=office_async_cancelled,
) catch {
ReadCancelled as error => raise error
_ => continue
}
let (stored_rows, stored_cols) = bounds
// the renderer treats merged areas as part of the visible sheet
// even past the last stored cell; mirror that so the report and
// the page agree on what was truncated
let mut max_row = stored_rows
let mut max_col = stored_cols
for
info in (workbook.get_merge_cells_info(name, without_values=true) catch {
_ => []
}) {
let end = info.get_end_axis()
let (c2, r2) = @xlsx.cell_name_to_coordinates(end) catch {
_ => continue
}
if r2 > max_row {
max_row = r2
}
if c2 > max_col {
max_col = c2
}
}
if max_row > office_preview_max_rows ||
max_col > office_preview_max_cols {
truncated_sheets.push(office_preview_clean_text(name))
}
}
None => ()
}
}
let stats : OfficePreviewStats = {
charts_rendered: office_preview_count_occurrences(
html, " (String, OfficePreviewStats) raise {
let mut total_image_bytes = 0
let mut images_embedded = 0
let mut images_omitted = 0
let conversion = @word.convert_to_html(source.bytes, convert_image=image => {
if image.data.length() > office_preview_max_image_bytes ||
total_image_bytes + image.data.length() >
office_preview_max_total_image_bytes {
images_omitted += 1
{
nodes: [],
messages: [
@docx_core.warning(
"preview image omitted by the bounded asset allowance",
),
],
}
} else {
total_image_bytes += image.data.length()
images_embedded += 1
@word.data_uri_image(image)
}
}) catch {
InvalidZip(message~)
| InvalidXml(message~)
| MissingPart(message~)
| Unsupported(message~)
| ResourceLimit(message~, ..)
| WriteResourceLimit(message~, ..) =>
raise CliFailure(
@lib.protocol_error(
"office.docx.preview_failed",
"DOCX preview rendering failed: " + bounded_text(message, 240),
details=Json::object({
"file": Json::string(bounded_text(source.file, 160)),
}),
),
)
}
let warnings : Array[String] = []
for message in conversion.messages {
if warnings.length() >= office_preview_max_warnings {
break
}
let text = match message {
Error(text) => text
Warning(text) => text
}
warnings.push(office_preview_clean_text(bounded_text(text, 240)))
}
let page = StringBuilder()
page.write_string(
"\n\n\n\n",
)
page.write_string(
"" + @xlsx2html.escape_html(source.file) + "\n\n\n\n")
page.write_string(conversion.value)
page.write_string("\n\n\n")
let stats : OfficePreviewStats = {
charts_rendered: 0,
charts_placeholder: 0,
truncated_sheets: [],
images_embedded,
images_omitted,
warnings,
}
(page.to_string(), stats)
}
///|
fn office_preview_report(
source : OfficeReadPackage,
output : String,
bytes_written : Int,
stats : OfficePreviewStats,
) -> Json {
let truncation : Map[String, Json] = {
"max_rows": Json::number(office_preview_max_rows.to_double()),
"max_cols": Json::number(office_preview_max_cols.to_double()),
"truncated_sheets": Json::array(
stats.truncated_sheets.map(name => Json::string(bounded_text(name, 80))),
),
"images_omitted": Json::number(stats.images_omitted.to_double()),
}
let fields : Map[String, Json] = {
"schema": Json::string(@lib.SCHEMA_PREVIEW_RESULT),
"file": Json::string(
office_preview_clean_text(bounded_text(source.file, 160)),
),
"format": Json::string(source.format.name()),
"output": Json::string(office_preview_clean_text(bounded_text(output, 160))),
"bytes_written": Json::number(bytes_written.to_double()),
"charts_rendered": Json::number(stats.charts_rendered.to_double()),
"charts_placeholder": Json::number(stats.charts_placeholder.to_double()),
"images_embedded": Json::number(stats.images_embedded.to_double()),
"truncation": Json::object(truncation),
}
if !stats.warnings.is_empty() {
fields["warnings"] = Json::array(
stats.warnings.map(warning => Json::string(warning)),
)
}
Json::object(fields)
}
///|
async fn run_preview(matches : @argparse.Matches) -> Unit {
let file = required_value(matches, "file")
let output = required_value(matches, "output")
let overwrite = matches.flags.get_or_default("overwrite", false)
let mode = office_validate_output_mode(matches)
guard output.to_lower().has_suffix(".html") else {
raise CliFailure(
@lib.protocol_error(
"office.invalid_arguments", "--output must end in .html so the preview artifact is unambiguous",
),
)
}
let source = read_office_package(file, cancelled=office_async_cancelled)
let (html, stats) = match source.format {
Xlsx => office_preview_render_xlsx(source)
Docx => office_preview_render_docx(source)
}
// a source file's stored strings can decode to isolated UTF-16
// surrogates; encoding must stay total and the report must say so
let (safe_html, replaced_units) = @xlsx2html.sanitize_utf16_lossy(html)
if replaced_units > 0 {
stats.warnings.push(
"\{replaced_units} unpaired UTF-16 unit(s) replaced with U+FFFD",
)
}
let payload = @utf8.encode(safe_html)
if payload.length() > office_preview_max_output_bytes {
raise CliFailure(
@lib.protocol_error(
"office.\{source.format.name()}.resource_limit",
"preview output exceeds the configured limit",
details=Json::object({
"resource": Json::string("preview_output_bytes"),
"limit": Json::number(office_preview_max_output_bytes.to_double()),
"actual": Json::number(payload.length().to_double()),
}),
),
)
}
if overwrite {
// replacement keeps the same staged-write path; the brief remove
// window is documented in the capability constraints
@afs.remove(output) catch {
_ => ()
}
}
@transaction.atomic_write_new(output, payload) catch {
@transaction.TransactionError(..) as error =>
raise transaction_failure(error)
error => raise error
}
let report = office_preview_report(source, output, payload.length(), stats)
match mode {
JsonDocument => println(@lib.output_success(report).stringify(indent=2))
JsonLines => println(@lib.output_success(report).stringify())
Human =>
println(
"\{source.format.name()} preview written to " +
office_preview_clean_text(human_text(output, 160)) +
" (\{payload.length()} bytes)",
)
}
}
///|
fn preview_command() -> @argparse.Command {
let summary = match @lib.find_capability_command("preview") {
Some(command) => command.summary
None => "Render a deterministic offline HTML preview"
}
Command(
"preview",
about=summary,
positionals=[
PositionArg(
"file",
about="path to an .xlsx or .docx file",
num_args=@argparse.ValueRange::single(),
),
],
options=[OptionArg("output", long="output", about="destination .html path")],
flags=[
FlagArg(
"overwrite",
long="overwrite",
about="replace an existing destination",
),
FlagArg("json", long="json", about="print office.output/1 JSON"),
FlagArg("jsonl", long="jsonl", about="print one office.output/1 line"),
],
)
}