chore: import upstream snapshot with attribution
Publish SDK (PyPI) / publish (push) Has been cancelled
Publish SDK (npm) / publish (@aionui/officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (windows-latest) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/officecli-sdk) (push) Has been cancelled
Publish SDK (npm) / publish (@officecli/sdk) (push) Has been cancelled
Publish SDK (npm) / publish (officecli-sdk) (push) Has been cancelled
SDK smoke / smoke (macos-latest) (push) Has been cancelled
SDK smoke / smoke (ubuntu-latest) (push) Has been cancelled
Skill parity / diff (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:09:29 +08:00
commit 8cb1f9f479
1189 changed files with 386007 additions and 0 deletions
+282
View File
@@ -0,0 +1,282 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.Json;
using System.Text.Json.Serialization;
namespace OfficeCli;
internal class LenientStringDictionaryConverter : JsonConverter<Dictionary<string, string>>
{
public override Dictionary<string, string>? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null) return null;
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Array form: ["key=value", ...]. This mirrors the single-command MCP
// `props` argument and the CLI `--prop key=value` flag, so an agent that
// learned props from `set`/`add` produces the same shape inside a batch
// item. Before this, batch props was object-only and every array-form
// batch failed with "Expected object for props" — observed as a 100%
// batch-failure for models that (correctly) reused the single-command
// props shape. Lenient split on the first '=' matches McpServer.ParseProps.
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndArray) return dict;
if (reader.TokenType != JsonTokenType.String)
throw new JsonException("Expected \"key=value\" string in props array");
var kv = reader.GetString()!;
var eq = kv.IndexOf('=');
if (eq > 0) dict[kv[..eq]] = kv[(eq + 1)..]; // skip malformed, as ParseProps does
}
throw new JsonException("Unexpected end of JSON");
}
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected object or [\"key=value\"] array for props");
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject) return dict;
if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException("Expected property name");
var key = reader.GetString()!;
reader.Read();
var value = reader.TokenType switch
{
JsonTokenType.String => reader.GetString()!,
JsonTokenType.Number => reader.TryGetInt64(out var l) ? l.ToString() : reader.GetDouble().ToString(),
JsonTokenType.True => "true",
JsonTokenType.False => "false",
JsonTokenType.Null => "",
_ => throw new JsonException($"Unexpected token {reader.TokenType} for prop value '{key}'")
};
dict[key] = value;
}
throw new JsonException("Unexpected end of JSON");
}
public override void Write(Utf8JsonWriter writer, Dictionary<string, string> value, JsonSerializerOptions options)
{
writer.WriteStartObject();
foreach (var kv in value)
writer.WriteString(kv.Key, kv.Value);
writer.WriteEndObject();
}
}
internal class BatchItemConverter : JsonConverter<BatchItem>
{
private static readonly LenientStringDictionaryConverter PropsConverter = new();
public override BatchItem? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected StartObject for BatchItem");
var item = new BatchItem();
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject) return item;
if (reader.TokenType != JsonTokenType.PropertyName)
throw new JsonException("Expected PropertyName");
var prop = reader.GetString()!;
reader.Read();
switch (prop.ToLowerInvariant())
{
case "command":
case "op":
item.Command = reader.GetString() ?? "";
break;
case "path": item.Path = reader.GetString(); break;
case "parent": item.Parent = reader.GetString(); break;
case "type": item.Type = reader.GetString(); break;
case "from": item.From = reader.GetString(); break;
case "index": item.Index = reader.TokenType == JsonTokenType.Null ? null : reader.GetInt32(); break;
case "after": item.After = reader.GetString(); break;
case "before": item.Before = reader.GetString(); break;
case "to": item.To = reader.GetString(); break;
case "path2": item.Path2 = reader.GetString(); break;
case "props": item.Props = PropsConverter.Read(ref reader, typeof(Dictionary<string, string>), options); break;
case "selector": item.Selector = reader.GetString(); break;
case "text": item.Text = reader.GetString(); break;
case "mode": item.Mode = reader.GetString(); break;
case "depth": item.Depth = reader.TokenType == JsonTokenType.Null ? null : reader.GetInt32(); break;
case "part": item.Part = reader.GetString(); break;
case "xpath": item.Xpath = reader.GetString(); break;
case "action": item.Action = reader.GetString(); break;
case "xml": item.Xml = reader.GetString(); break;
default: reader.Skip(); break;
}
}
throw new JsonException("Unexpected end of JSON for BatchItem");
}
public override void Write(Utf8JsonWriter writer, BatchItem value, JsonSerializerOptions options)
{
writer.WriteStartObject();
if (!string.IsNullOrEmpty(value.Command)) writer.WriteString("command", value.Command);
if (value.Path != null) writer.WriteString("path", value.Path);
if (value.Parent != null) writer.WriteString("parent", value.Parent);
if (value.Type != null) writer.WriteString("type", value.Type);
if (value.From != null) writer.WriteString("from", value.From);
if (value.Index.HasValue) writer.WriteNumber("index", value.Index.Value);
if (value.After != null) writer.WriteString("after", value.After);
if (value.Before != null) writer.WriteString("before", value.Before);
if (value.To != null) writer.WriteString("to", value.To);
if (value.Path2 != null) writer.WriteString("path2", value.Path2);
if (value.Props != null) { writer.WritePropertyName("props"); PropsConverter.Write(writer, value.Props, options); }
if (value.Selector != null) writer.WriteString("selector", value.Selector);
if (value.Text != null) writer.WriteString("text", value.Text);
if (value.Mode != null) writer.WriteString("mode", value.Mode);
if (value.Depth.HasValue) writer.WriteNumber("depth", value.Depth.Value);
if (value.Part != null) writer.WriteString("part", value.Part);
if (value.Xpath != null) writer.WriteString("xpath", value.Xpath);
if (value.Action != null) writer.WriteString("action", value.Action);
if (value.Xml != null) writer.WriteString("xml", value.Xml);
writer.WriteEndObject();
}
}
[JsonConverter(typeof(BatchItemConverter))]
public class BatchItem
{
public string Command { get; set; } = "";
public string? Path { get; set; }
public string? Parent { get; set; }
public string? Type { get; set; }
public string? From { get; set; }
public int? Index { get; set; }
public string? After { get; set; }
public string? Before { get; set; }
public string? To { get; set; }
// swap's second element. Canonical name across the single-command MCP tool
// and the CLI (`swap path1 path2`); a batch swap that reused that name was
// previously dropped (BatchItem had no path2), so swap only worked via the
// off-name `to`. Accept both — see the swap case in ExecuteBatchItem.
public string? Path2 { get; set; }
public Dictionary<string, string>? Props { get; set; }
public string? Selector { get; set; }
public string? Text { get; set; }
public string? Mode { get; set; }
public int? Depth { get; set; }
public string? Part { get; set; }
public string? Xpath { get; set; }
public string? Action { get; set; }
public string? Xml { get; set; }
internal static readonly HashSet<string> KnownFields = new(StringComparer.OrdinalIgnoreCase)
{
"command", "op", "path", "parent", "type", "from", "index", "after", "before", "to", "path2",
"props", "selector", "text", "mode", "depth", "part", "xpath", "action", "xml"
};
public ResidentRequest ToResidentRequest()
{
var req = new ResidentRequest { Command = Command };
if (Path != null) req.Args["path"] = Path;
if (Parent != null) req.Args["parent"] = Parent;
if (Type != null) req.Args["type"] = Type;
if (From != null) req.Args["from"] = From;
if (Index.HasValue) req.Args["index"] = Index.Value.ToString();
if (After != null) req.Args["after"] = After;
if (Before != null) req.Args["before"] = Before;
if (To != null) req.Args["to"] = To;
if (Path2 != null) req.Args["path2"] = Path2;
if (Selector != null) req.Args["selector"] = Selector;
if (Text != null) req.Args["text"] = Text;
if (Mode != null) req.Args["mode"] = Mode;
if (Depth.HasValue) req.Args["depth"] = Depth.Value.ToString();
if (Part != null) req.Args["part"] = Part;
if (Xpath != null) req.Args["xpath"] = Xpath;
if (Action != null) req.Args["action"] = Action;
if (Xml != null) req.Args["xml"] = Xml;
if (Props != null)
req.Props = Props;
return req;
}
}
[JsonConverter(typeof(BatchResultConverter))]
public class BatchResult
{
public int Index { get; set; }
public bool Success { get; set; }
public string? Output { get; set; }
public string? Error { get; set; }
/// <summary>The original batch item, included when the command fails so the agent can inspect/retry.</summary>
public BatchItem? Item { get; set; }
}
/// <summary>
/// Custom converter for BatchResult that writes Output as raw JSON (not double-encoded)
/// when the Output string is valid JSON.
/// </summary>
internal class BatchResultConverter : JsonConverter<BatchResult>
{
public override BatchResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using var doc = JsonDocument.ParseValue(ref reader);
var root = doc.RootElement;
var result = new BatchResult();
if (root.TryGetProperty("index", out var idx)) result.Index = idx.GetInt32();
if (root.TryGetProperty("success", out var suc)) result.Success = suc.GetBoolean();
if (root.TryGetProperty("output", out var outp)) result.Output = outp.ValueKind == JsonValueKind.String ? outp.GetString() : outp.GetRawText();
if (root.TryGetProperty("error", out var err)) result.Error = err.GetString();
if (root.TryGetProperty("item", out var itm)) result.Item = JsonSerializer.Deserialize(itm.GetRawText(), BatchJsonContext.Default.BatchItem);
return result;
}
public override void Write(Utf8JsonWriter writer, BatchResult value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber("index", value.Index);
writer.WriteBoolean("success", value.Success);
if (value.Output != null)
{
// If Output is valid JSON (object or array), write it as raw JSON to avoid double-encoding
if (IsJsonObjectOrArray(value.Output))
{
writer.WritePropertyName("output");
using var doc = JsonDocument.Parse(value.Output);
doc.RootElement.WriteTo(writer);
}
else
{
writer.WriteString("output", value.Output);
}
}
if (value.Error != null)
{
writer.WriteString("error", value.Error);
if (value.Item != null)
{
writer.WritePropertyName("item");
JsonSerializer.Serialize(writer, value.Item, BatchJsonContext.Default.BatchItem);
}
}
writer.WriteEndObject();
}
private static bool IsJsonObjectOrArray(string s)
{
if (string.IsNullOrWhiteSpace(s)) return false;
var trimmed = s.TrimStart();
if (trimmed.Length == 0) return false;
if (trimmed[0] != '{' && trimmed[0] != '[') return false;
try
{
using var doc = JsonDocument.Parse(s);
return doc.RootElement.ValueKind is JsonValueKind.Object or JsonValueKind.Array;
}
catch { return false; }
}
}
[JsonSourceGenerationOptions]
[JsonSerializable(typeof(BatchItem))]
[JsonSerializable(typeof(List<BatchItem>))]
[JsonSerializable(typeof(List<BatchResult>))]
internal partial class BatchJsonContext : JsonSerializerContext;
+715
View File
@@ -0,0 +1,715 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
using DocumentFormat.OpenXml.Wordprocessing;
using DocumentFormat.OpenXml.Presentation;
using OfficeCli.Core;
namespace OfficeCli;
public static class BlankDocCreator
{
public static void Create(string path, string? locale = null, bool minimal = false)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
switch (ext)
{
case ".xlsx":
CreateExcel(path);
break;
case ".docx":
CreateWord(path, locale, minimal);
break;
case ".pptx":
CreatePowerPoint(path);
break;
default:
if (TryCreateViaPlugin(path, ext)) break;
throw new NotSupportedException($"Unsupported file type: {ext}. Supported: .docx, .xlsx, .pptx, or any extension served by an installed format-handler plugin that implements `create`.");
}
}
/// <summary>
/// Delegate creation of an unknown extension to a registered format-handler
/// plugin, if one exists for that extension and exposes a `create &lt;path&gt;`
/// CLI subcommand. Returns <c>true</c> if a plugin was found and produced
/// the file successfully. Generic per plugins/plugin-protocol.md — keeps
/// BlankDocCreator format-agnostic; any plugin that implements the
/// `create` subcommand on its executable participates.
/// </summary>
private static bool TryCreateViaPlugin(string path, string ext)
{
var plugin = OfficeCli.Core.Plugins.PluginRegistry.FindFor(
OfficeCli.Core.Plugins.PluginKind.FormatHandler, ext);
if (plugin is null) return false;
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = plugin.ExecutablePath,
ArgumentList = { "create", System.IO.Path.GetFullPath(path) },
UseShellExecute = false,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var proc = System.Diagnostics.Process.Start(psi);
if (proc is null) return false;
// Bound the wait: a hung plugin previously blocked `create` forever
// (stderr is the only redirected stream, so the read itself cannot
// deadlock, but WaitForExit had no timeout or Kill).
var stderrTask = proc.StandardError.ReadToEndAsync();
if (!proc.WaitForExit(60_000))
{
try { proc.Kill(true); } catch { }
throw new OfficeCli.Core.CliException(
$"Format-handler plugin '{plugin.Manifest.Name}' timed out creating {path} (60s).")
{ Code = "plugin_create_failed" };
}
var stderr = stderrTask.Result;
if (proc.ExitCode != 0)
{
// Treat unknown-subcommand exit-64 as "plugin doesn't implement
// create" — fall back to NotSupportedException so the user sees
// the same error they'd see without any plugin installed.
if (proc.ExitCode == 64) return false;
throw new OfficeCli.Core.CliException(
$"Format-handler plugin '{plugin.Manifest.Name}' failed to create {path}: {stderr.Trim()}")
{ Code = "plugin_create_failed" };
}
return true;
}
private static void CreateExcel(string path)
{
using var doc = SpreadsheetDocument.Create(path, SpreadsheetDocumentType.Workbook);
var workbookPart = doc.AddWorkbookPart();
var worksheetPart = workbookPart.AddNewPart<WorksheetPart>("rId1");
worksheetPart.Worksheet = new Worksheet(new SheetData());
worksheetPart.Worksheet.Save();
workbookPart.Workbook = new Workbook(
new Sheets(
new Sheet { Id = "rId1", SheetId = 1, Name = "Sheet1" }
)
);
workbookPart.Workbook.Save();
// theme1.xml — every real Excel workbook ships a theme part, and so do
// officecli's docx and pptx blanks. xlsx alone omitted it, which made
// workbook `theme.*` edits silently no-op (ThemeHandler had no part to
// write into) and left theme-colour lookups empty — a cross-format
// parity gap. Stamp the same shared default theme here so theme.* set/get
// works on a freshly created workbook like it does for Word/PowerPoint.
var themePart = workbookPart.AddNewPart<DocumentFormat.OpenXml.Packaging.ThemePart>();
themePart.Theme = BuildDefaultTheme(null, null);
themePart.Theme.Save();
OfficeCliMetadata.StampOnCreate(doc);
}
private static void CreateWord(string path, string? locale = null, bool minimal = false)
{
using var doc = WordprocessingDocument.Create(path, WordprocessingDocumentType.Document);
var mainPart = doc.AddMainDocumentPart();
// Locale-implied RTL — Arabic, Hebrew, Persian, Urdu, … get bidi
// defaults stamped onto sectPr + pPrDefault so users don't have to
// set direction=rtl on every paragraph.
bool isRtl = OfficeCli.Core.LocaleFontRegistry.IsRightToLeft(locale);
// Section with A4 page size, standard margins, and no docGrid snap.
// <w:bidi/> on sectPr makes the section's layout RTL (column order,
// anchor edge for page numbers, etc.) when the locale is RTL.
// CT_SectPr schema order: pgSz → pgMar → … → bidi → docGrid.
var sectPr = new SectionProperties(
new PageSize { Width = WordPageDefaults.A4WidthTwips, Height = WordPageDefaults.A4HeightTwips },
new PageMargin { Top = 1440, Right = 1800U, Bottom = 1440, Left = 1800U }
);
if (isRtl)
sectPr.AppendChild(new BiDi());
sectPr.AppendChild(new DocGrid { Type = DocGridValues.Default });
// Compatibility: do not compress punctuation spacing
// Schema order: characterSpacingControl must come before compat in w:settings
//
// `compatibilityMode=15` is the Word 2013+ modern-mode flag.
// Without it, Word opens the doc in "Compatibility Mode" (the title
// bar shows the indicator and the UI silently disables several
// newer features). Word's own blank-document save always stamps
// this value; we did not, so every doc officecli generated looked
// like a Word 2010 file to readers. The other four compatSettings
// listed below are what current Word writes alongside; including
// them keeps the settings block byte-similar to Word's own output
// so subsequent edits by Word don't churn this block.
var settings = new DocumentFormat.OpenXml.Wordprocessing.Settings(
new CharacterSpacingControl { Val = CharacterSpacingValues.DoNotCompress },
new Compatibility(
new SpaceForUnderline(),
new BalanceSingleByteDoubleByteWidth(),
new DoNotLeaveBackslashAlone(),
new UnderlineTrailingSpaces(),
new DoNotExpandShiftReturn(),
new AdjustLineHeightInTable(),
new CompatibilitySetting
{
Name = new EnumValue<CompatSettingNameValues>(CompatSettingNameValues.CompatibilityMode),
Val = new StringValue("15"),
Uri = new StringValue("http://schemas.microsoft.com/office/word")
},
new CompatibilitySetting
{
Name = new EnumValue<CompatSettingNameValues>(CompatSettingNameValues.OverrideTableStyleFontSizeAndJustification),
Val = new StringValue("1"),
Uri = new StringValue("http://schemas.microsoft.com/office/word")
},
new CompatibilitySetting
{
Name = new EnumValue<CompatSettingNameValues>(CompatSettingNameValues.EnableOpenTypeFeatures),
Val = new StringValue("1"),
Uri = new StringValue("http://schemas.microsoft.com/office/word")
},
new CompatibilitySetting
{
Name = new EnumValue<CompatSettingNameValues>(CompatSettingNameValues.DoNotFlipMirrorIndents),
Val = new StringValue("1"),
Uri = new StringValue("http://schemas.microsoft.com/office/word")
},
new CompatibilitySetting
{
Name = new EnumValue<CompatSettingNameValues>(CompatSettingNameValues.DifferentiateMultirowTableHeaders),
Val = new StringValue("1"),
Uri = new StringValue("http://schemas.microsoft.com/office/word")
}
)
);
// i18n: stamp themeFontLang from --locale so HTML preview, screen
// readers, and Word's per-script font fallback know
// the document's primary language. Routes the locale to EastAsia
// (CJK), Bidi (Arabic / Hebrew / Persian / Urdu / Thai / Hindi),
// or the bare Val attribute otherwise.
if (!string.IsNullOrEmpty(locale))
{
var tfl = new DocumentFormat.OpenXml.Wordprocessing.ThemeFontLanguages();
var langKey = locale.Replace('_', '-').ToLowerInvariant().Split('-')[0];
switch (langKey)
{
case "zh":
case "ja":
case "ko":
tfl.EastAsia = locale;
break;
case "ar":
case "he":
case "fa":
case "ur":
case "th":
case "hi":
tfl.Bidi = locale;
break;
default:
tfl.Val = locale;
break;
}
// CT_Settings sequence: characterSpacingControl (~pos 63),
// compat (~pos 78), themeFontLang (~pos 80). themeFontLang
// belongs AFTER compat, not at the front. Earlier revisions of
// this file put it before characterSpacingControl which made
// every RTL-locale doc fail OOXML validation with "unexpected
// child characterSpacingControl" — the validator was actually
// complaining about themeFontLang being out of order, but
// surfaced the next sibling as the offending element.
var compat = settings.GetFirstChild<Compatibility>();
if (compat != null) compat.InsertAfterSelf(tfl);
else settings.AppendChild(tfl);
}
var settingsPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.DocumentSettingsPart>();
settingsPart.Settings = settings;
settingsPart.Settings.Save();
var document = new Document(new Body(sectPr));
// Declare common namespaces on <w:document> so later raw-set
// injections (DrawingML textboxes <wps:wsp>, VML fallbacks <v:shape>,
// pictures <pic:pic>, math <m:oMath>, ...) validate without each
// call site re-declaring them. Mirrors what Word itself stamps on
// save. Without this, mc:AlternateContent / mc:Choice Requires="wps"
// fails MarkupCompatibility validation because the wps prefix is
// not in scope at the AlternateContent element.
document.AddNamespaceDeclaration("r", "http://schemas.openxmlformats.org/officeDocument/2006/relationships");
document.AddNamespaceDeclaration("m", "http://schemas.openxmlformats.org/officeDocument/2006/math");
document.AddNamespaceDeclaration("v", "urn:schemas-microsoft-com:vml");
document.AddNamespaceDeclaration("o", "urn:schemas-microsoft-com:office:office");
document.AddNamespaceDeclaration("w10", "urn:schemas-microsoft-com:office:word");
document.AddNamespaceDeclaration("wne", "http://schemas.microsoft.com/office/word/2006/wordml");
document.AddNamespaceDeclaration("wp", "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing");
document.AddNamespaceDeclaration("wp14", "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing");
// w14 (Office 2010 wordml extensions — paraId / textId / docId) was
// listed in mc:Ignorable below but never declared on the root, so
// every blank docx failed validation with "Ignorable contains an
// invalid prefix 'w14'". paraId attributes do get emitted by Add
// helpers on every paragraph, so leaving this undeclared was a
// real schema violation, not cosmetic.
document.AddNamespaceDeclaration("w14", "http://schemas.microsoft.com/office/word/2010/wordml");
document.AddNamespaceDeclaration("a", "http://schemas.openxmlformats.org/drawingml/2006/main");
document.AddNamespaceDeclaration("pic", "http://schemas.openxmlformats.org/drawingml/2006/picture");
document.AddNamespaceDeclaration("wps", "http://schemas.microsoft.com/office/word/2010/wordprocessingShape");
document.AddNamespaceDeclaration("wpg", "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup");
document.AddNamespaceDeclaration("wpi", "http://schemas.microsoft.com/office/word/2010/wordprocessingInk");
document.AddNamespaceDeclaration("wpc", "http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas");
document.AddNamespaceDeclaration("w15", "http://schemas.microsoft.com/office/word/2012/wordml");
// Mark 2010+/2012 namespaces as Ignorable so older readers degrade gracefully.
document.MCAttributes ??= new DocumentFormat.OpenXml.MarkupCompatibilityAttributes();
var existingIgnorable = document.MCAttributes.Ignorable?.Value;
var ignorableTokens = new System.Collections.Generic.HashSet<string>(
(existingIgnorable ?? "").Split(' ', System.StringSplitOptions.RemoveEmptyEntries));
// Only mark prefixes that appear unwrapped (outside mc:AlternateContent)
// as Ignorable — w14/wp14/w15 carry attributes like paraId/anchorId
// directly. wps/wpg/wpi/wpc only appear inside mc:Choice and are
// already gated by mc:Fallback, so they don't need (and shouldn't get)
// Ignorable. Mirrors the docxexport MainXmlNamespaces.
foreach (var p in new[] { "w14", "w15", "wp14" })
ignorableTokens.Add(p);
document.MCAttributes.Ignorable = string.Join(" ", ignorableTokens);
mainPart.Document = document;
// Two paths: full (default) emits Word-aligned baseline (Calibri 11pt
// + Normal style + theme1.xml — matches the de-facto baseline, which
// is what Word actually writes); minimal emits raw OOXML (TNR, no sz,
// no Normal, no theme). The
// minimal path is the prior officecli behavior; the full path was
// added so docs created by officecli render identically in Word /
// / cli preview without relying on each renderer's
// Normal.dotm fallback heuristics.
//
// Resolve locale-specific defaults from LocaleFontRegistry.
// Without a locale, only Latin slots are populated so the
// host application's UI-locale defaults fill EastAsia / CS as needed.
var (locLatin, locEa, locCs) = OfficeCli.Core.LocaleFontRegistry.Resolve(locale);
var stylesPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.StyleDefinitionsPart>();
if (minimal)
{
// Minimal path: docDefaults with rFonts only (Times New Roman),
// no sz, no spacing, no Normal style, no theme. Use this for
// testing the cli reader's fallback path or producing maximally
// compact output. Matches `officecli create` output before the
// Word-aligned baseline was added.
var minDocDefaultFonts = new RunFonts
{
Ascii = locLatin ?? "Times New Roman",
HighAnsi = locLatin ?? "Times New Roman",
};
if (!string.IsNullOrEmpty(locEa)) minDocDefaultFonts.EastAsia = locEa;
if (!string.IsNullOrEmpty(locCs)) minDocDefaultFonts.ComplexScript = locCs;
// pPrDefault/bidi for RTL locales — sets paragraph direction as
// doc-wide default so any paragraph added later inherits RTL
// without per-paragraph direction=rtl. Schema-correct location
// for the default — rPrDefault/rtl is rejected by the OOXML
// validator (CT_RPrDefault excludes <w:rtl/>); pPrDefault/bidi
// is the canonical path Word uses.
var pPrDefaultBase = isRtl ? new ParagraphPropertiesBaseStyle(new BiDi()) : null;
stylesPart.Styles = new Styles(
new DocDefaults(
new RunPropertiesDefault(new RunPropertiesBaseStyle(minDocDefaultFonts)),
pPrDefaultBase != null
? new ParagraphPropertiesDefault(pPrDefaultBase)
: new ParagraphPropertiesDefault()
)
);
stylesPart.Styles.Save();
}
else
{
var docDefaultFonts = new RunFonts
{
Ascii = locLatin ?? OfficeDefaultFonts.MinorLatin, // Calibri
HighAnsi = locLatin ?? OfficeDefaultFonts.MinorLatin,
};
if (!string.IsNullOrEmpty(locEa)) docDefaultFonts.EastAsia = locEa;
if (!string.IsNullOrEmpty(locCs)) docDefaultFonts.ComplexScript = locCs;
// Normal style — default="1". Carry the Office 2013+ Normal
// baseline (line=259/1.08 ×, no after) on the Normal pPr itself,
// not on pPrDefault — cli's reader only walks the style chain via
// ResolveSpacingFromStyle and doesn't yet inherit from pPrDefault.
// Putting it on Normal keeps pPrDefault free for paragraph-shape
// defaults (autoSpaceDE/DN, kinsoku, …) without spacing leakage.
//
// Why 1.08 × not 1.15 ×: empirical (stress-C measurement) — when
// a list line has a 14 pt marker over 11 pt body, Word renders
// the line at 14 × 1.08 × calibri-ratio = 18.45pt; cli with
// 1.15 × renders at 14 × 1.15 × ratio = 19.65pt (1.3pt/paragraph drift
// accumulating across the doc). Office 2013+ Normal IS 1.08 ×;
// matching that here matches what Word actually does.
var normalStyle = new Style(
new StyleName { Val = "Normal" },
new PrimaryStyle(),
new StyleParagraphProperties(
new SpacingBetweenLines
{
After = "0",
Line = "259",
LineRule = LineSpacingRuleValues.Auto,
}
)
)
{
Type = StyleValues.Paragraph,
StyleId = "Normal",
Default = true,
};
// pPrDefault/bidi for RTL locales — see minimal-path comment above.
var pPrDefaultBaseN = isRtl ? new ParagraphPropertiesBaseStyle(new BiDi()) : null;
stylesPart.Styles = new Styles(
new DocDefaults(
new RunPropertiesDefault(
new RunPropertiesBaseStyle(
docDefaultFonts,
new DocumentFormat.OpenXml.Wordprocessing.FontSize { Val = "22" }, // 11pt
new FontSizeComplexScript { Val = "22" }
)
),
pPrDefaultBaseN != null
? new ParagraphPropertiesDefault(pPrDefaultBaseN)
: new ParagraphPropertiesDefault()
),
normalStyle
);
stylesPart.Styles.Save();
}
// Declare + Ignore the w14 (Office 2010 wordml) extension namespace on
// the styles root. A dumped docDefaults can carry <w14:ligatures> (and
// similar w14 run properties) raw-set verbatim from the source; without
// mc:Ignorable covering w14 the strict validator rejects them ("invalid
// child element w14:ligatures") even though Word itself tolerates them.
// Mirrors the document-root mc:Ignorable handling above.
stylesPart.Styles.AddNamespaceDeclaration("w14", "http://schemas.microsoft.com/office/word/2010/wordml");
stylesPart.Styles.MCAttributes ??= new DocumentFormat.OpenXml.MarkupCompatibilityAttributes();
var stylesIgnorable = new HashSet<string>(
(stylesPart.Styles.MCAttributes.Ignorable?.Value ?? "")
.Split(' ', System.StringSplitOptions.RemoveEmptyEntries));
if (stylesIgnorable.Add("w14"))
stylesPart.Styles.MCAttributes.Ignorable = string.Join(" ", stylesIgnorable);
stylesPart.Styles.Save();
// theme1.xml — Office's minor=Calibri / major=Calibri Light. Without
// a theme part, anything that looks up `themeFonts` (heading/body
// theme references in styles.xml) gets nothing — emit a minimal
// theme so future styles can reference it. Skipped on the minimal
// path so its output stays free of theme dependencies.
if (!minimal)
{
var themePart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.ThemePart>();
themePart.Theme = BuildDefaultTheme(locEa, locCs);
themePart.Theme.Save();
}
var numberingPart = mainPart.AddNewPart<DocumentFormat.OpenXml.Packaging.NumberingDefinitionsPart>();
numberingPart.Numbering = new DocumentFormat.OpenXml.Wordprocessing.Numbering();
numberingPart.Numbering.Save();
mainPart.Document.Save();
OfficeCliMetadata.StampOnCreate(doc);
}
/// <summary>
/// Builds the standard Office Word theme (clrScheme + fontScheme + fmtScheme)
/// that a blank document stamps into theme1.xml. Shared with
/// <c>WordBatchEmitter.EmitThemeRaw</c> so a dump of a theme-less source
/// emits a schema-complete theme placeholder (one Word can open) rather
/// than a bare &lt;a:theme/&gt; stub. Pass the locale-resolved East-Asian /
/// complex-script typefaces (or null for the locale-neutral default).
/// </summary>
internal static DocumentFormat.OpenXml.Drawing.Theme BuildDefaultTheme(string? locEa, string? locCs)
=> new DocumentFormat.OpenXml.Drawing.Theme(
new DocumentFormat.OpenXml.Drawing.ThemeElements(
new DocumentFormat.OpenXml.Drawing.ColorScheme(
new DocumentFormat.OpenXml.Drawing.Dark1Color(new DocumentFormat.OpenXml.Drawing.SystemColor { Val = DocumentFormat.OpenXml.Drawing.SystemColorValues.WindowText, LastColor = "000000" }),
new DocumentFormat.OpenXml.Drawing.Light1Color(new DocumentFormat.OpenXml.Drawing.SystemColor { Val = DocumentFormat.OpenXml.Drawing.SystemColorValues.Window, LastColor = "FFFFFF" }),
new DocumentFormat.OpenXml.Drawing.Dark2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Dark2 }),
new DocumentFormat.OpenXml.Drawing.Light2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Light2 }),
new DocumentFormat.OpenXml.Drawing.Accent1Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent1 }),
new DocumentFormat.OpenXml.Drawing.Accent2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent2 }),
new DocumentFormat.OpenXml.Drawing.Accent3Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent3 }),
new DocumentFormat.OpenXml.Drawing.Accent4Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent4 }),
new DocumentFormat.OpenXml.Drawing.Accent5Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent5 }),
new DocumentFormat.OpenXml.Drawing.Accent6Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent6 }),
new DocumentFormat.OpenXml.Drawing.Hyperlink(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Hyperlink }),
new DocumentFormat.OpenXml.Drawing.FollowedHyperlinkColor(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.FollowedHyperlink })
) { Name = "Office" },
new DocumentFormat.OpenXml.Drawing.FontScheme(
new DocumentFormat.OpenXml.Drawing.MajorFont(
new DocumentFormat.OpenXml.Drawing.LatinFont { Typeface = OfficeDefaultFonts.MajorLatin },
new DocumentFormat.OpenXml.Drawing.EastAsianFont { Typeface = locEa ?? "" },
new DocumentFormat.OpenXml.Drawing.ComplexScriptFont { Typeface = locCs ?? "" }
),
new DocumentFormat.OpenXml.Drawing.MinorFont(
new DocumentFormat.OpenXml.Drawing.LatinFont { Typeface = OfficeDefaultFonts.MinorLatin },
new DocumentFormat.OpenXml.Drawing.EastAsianFont { Typeface = locEa ?? "" },
new DocumentFormat.OpenXml.Drawing.ComplexScriptFont { Typeface = locCs ?? "" }
)
) { Name = "Office" },
new DocumentFormat.OpenXml.Drawing.FormatScheme(
new DocumentFormat.OpenXml.Drawing.FillStyleList(
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })
),
new DocumentFormat.OpenXml.Drawing.LineStyleList(
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 6350, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat },
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 12700, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat },
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 19050, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat }
),
new DocumentFormat.OpenXml.Drawing.EffectStyleList(
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList()),
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList()),
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList())
),
new DocumentFormat.OpenXml.Drawing.BackgroundFillStyleList(
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })
)
) { Name = "Office" }
)
) { Name = "Office Theme" };
private static void CreatePowerPoint(string path)
{
using var doc = PresentationDocument.Create(path, PresentationDocumentType.Presentation);
var presentationPart = doc.AddPresentationPart();
// Create SlideMaster + SlideLayout (required by spec)
var slideMasterPart = presentationPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideMasterPart>("rId1");
var slideLayoutPart = slideMasterPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideLayoutPart>("rId1");
// Theme must be under presentationPart, then shared to slideMaster
var themePart = presentationPart.AddNewPart<DocumentFormat.OpenXml.Packaging.ThemePart>("rId2");
slideMasterPart.AddPart(themePart);
themePart.Theme = new DocumentFormat.OpenXml.Drawing.Theme(
new DocumentFormat.OpenXml.Drawing.ThemeElements(
new DocumentFormat.OpenXml.Drawing.ColorScheme(
new DocumentFormat.OpenXml.Drawing.Dark1Color(new DocumentFormat.OpenXml.Drawing.SystemColor { Val = DocumentFormat.OpenXml.Drawing.SystemColorValues.WindowText, LastColor = "000000" }),
new DocumentFormat.OpenXml.Drawing.Light1Color(new DocumentFormat.OpenXml.Drawing.SystemColor { Val = DocumentFormat.OpenXml.Drawing.SystemColorValues.Window, LastColor = "FFFFFF" }),
new DocumentFormat.OpenXml.Drawing.Dark2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Dark2 }),
new DocumentFormat.OpenXml.Drawing.Light2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Light2 }),
new DocumentFormat.OpenXml.Drawing.Accent1Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent1 }),
new DocumentFormat.OpenXml.Drawing.Accent2Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent2 }),
new DocumentFormat.OpenXml.Drawing.Accent3Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent3 }),
new DocumentFormat.OpenXml.Drawing.Accent4Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent4 }),
new DocumentFormat.OpenXml.Drawing.Accent5Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent5 }),
new DocumentFormat.OpenXml.Drawing.Accent6Color(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Accent6 }),
new DocumentFormat.OpenXml.Drawing.Hyperlink(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.Hyperlink }),
new DocumentFormat.OpenXml.Drawing.FollowedHyperlinkColor(new DocumentFormat.OpenXml.Drawing.RgbColorModelHex { Val = OfficeDefaultThemeColors.FollowedHyperlink })
) { Name = "Office" },
new DocumentFormat.OpenXml.Drawing.FontScheme(
new DocumentFormat.OpenXml.Drawing.MajorFont(
new DocumentFormat.OpenXml.Drawing.LatinFont { Typeface = OfficeDefaultFonts.MajorLatin },
new DocumentFormat.OpenXml.Drawing.EastAsianFont { Typeface = "" },
new DocumentFormat.OpenXml.Drawing.ComplexScriptFont { Typeface = "" }
),
new DocumentFormat.OpenXml.Drawing.MinorFont(
new DocumentFormat.OpenXml.Drawing.LatinFont { Typeface = OfficeDefaultFonts.MinorLatin },
new DocumentFormat.OpenXml.Drawing.EastAsianFont { Typeface = "" },
new DocumentFormat.OpenXml.Drawing.ComplexScriptFont { Typeface = "" }
)
) { Name = "Office" },
new DocumentFormat.OpenXml.Drawing.FormatScheme(
new DocumentFormat.OpenXml.Drawing.FillStyleList(
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })
),
new DocumentFormat.OpenXml.Drawing.LineStyleList(
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 6350, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat },
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 12700, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat },
new DocumentFormat.OpenXml.Drawing.Outline(new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })) { Width = 19050, CapType = DocumentFormat.OpenXml.Drawing.LineCapValues.Flat }
),
new DocumentFormat.OpenXml.Drawing.EffectStyleList(
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList()),
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList()),
new DocumentFormat.OpenXml.Drawing.EffectStyle(new DocumentFormat.OpenXml.Drawing.EffectList())
),
new DocumentFormat.OpenXml.Drawing.BackgroundFillStyleList(
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor }),
new DocumentFormat.OpenXml.Drawing.SolidFill(new DocumentFormat.OpenXml.Drawing.SchemeColor { Val = DocumentFormat.OpenXml.Drawing.SchemeColorValues.PhColor })
)
) { Name = "Office" }
)
) { Name = "Office Theme" };
themePart.Theme.Save();
// Layout 1: Blank
slideLayoutPart.SlideLayout = new DocumentFormat.OpenXml.Presentation.SlideLayout(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties()
)
) { Name = "Blank" }
) { Type = DocumentFormat.OpenXml.Presentation.SlideLayoutValues.Blank };
slideLayoutPart.SlideLayout.Save();
slideLayoutPart.AddPart(slideMasterPart);
// Layout 2: Title Slide (title + subtitle)
var titleLayoutPart = slideMasterPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideLayoutPart>("rId2");
titleLayoutPart.SlideLayout = new DocumentFormat.OpenXml.Presentation.SlideLayout(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties(),
CreateLayoutPlaceholder(2, "Title", PlaceholderValues.CenteredTitle, 685800, 2130425, 7772400, 1470025),
CreateLayoutPlaceholder(3, "Subtitle", PlaceholderValues.SubTitle, 1371600, 3886200, 6400800, 1752600, idx: 1)
)
) { Name = "Title Slide" }
) { Type = DocumentFormat.OpenXml.Presentation.SlideLayoutValues.Title };
titleLayoutPart.SlideLayout.Save();
titleLayoutPart.AddPart(slideMasterPart);
// Layout 3: Title and Content
var contentLayoutPart = slideMasterPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideLayoutPart>("rId3");
contentLayoutPart.SlideLayout = new DocumentFormat.OpenXml.Presentation.SlideLayout(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties(),
CreateLayoutPlaceholder(2, "Title", PlaceholderValues.Title, 838200, 365125, 10515600, 1325563),
CreateLayoutPlaceholder(3, "Content", PlaceholderValues.Body, 838200, 1825625, 10515600, 4351338, idx: 1)
)
) { Name = "Title and Content" }
) { Type = DocumentFormat.OpenXml.Presentation.SlideLayoutValues.ObjectText };
contentLayoutPart.SlideLayout.Save();
contentLayoutPart.AddPart(slideMasterPart);
// Layout 4: Two Content
var twoContentLayoutPart = slideMasterPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideLayoutPart>("rId4");
twoContentLayoutPart.SlideLayout = new DocumentFormat.OpenXml.Presentation.SlideLayout(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties(),
CreateLayoutPlaceholder(2, "Title", PlaceholderValues.Title, 838200, 365125, 10515600, 1325563),
CreateLayoutPlaceholder(3, "Content Left", PlaceholderValues.Body, 838200, 1825625, 5181600, 4351338, idx: 1),
CreateLayoutPlaceholder(4, "Content Right", PlaceholderValues.Body, 6172200, 1825625, 5181600, 4351338, idx: 2)
)
) { Name = "Two Content" }
) { Type = DocumentFormat.OpenXml.Presentation.SlideLayoutValues.TwoColumnText };
twoContentLayoutPart.SlideLayout.Save();
twoContentLayoutPart.AddPart(slideMasterPart);
// Layout 5: Title Only (title placeholder, no body)
var titleOnlyLayoutPart = slideMasterPart.AddNewPart<DocumentFormat.OpenXml.Packaging.SlideLayoutPart>("rId5");
titleOnlyLayoutPart.SlideLayout = new DocumentFormat.OpenXml.Presentation.SlideLayout(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties(),
CreateLayoutPlaceholder(2, "Title", PlaceholderValues.Title, 838200, 365125, 10515600, 1325563)
)
) { Name = "Title Only" }
) { Type = DocumentFormat.OpenXml.Presentation.SlideLayoutValues.TitleOnly };
titleOnlyLayoutPart.SlideLayout.Save();
titleOnlyLayoutPart.AddPart(slideMasterPart);
slideMasterPart.SlideMaster = new DocumentFormat.OpenXml.Presentation.SlideMaster(
new DocumentFormat.OpenXml.Presentation.CommonSlideData(
new DocumentFormat.OpenXml.Presentation.ShapeTree(
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeProperties(
new DocumentFormat.OpenXml.Presentation.NonVisualDrawingProperties { Id = 1, Name = "" },
new DocumentFormat.OpenXml.Presentation.NonVisualGroupShapeDrawingProperties(),
new DocumentFormat.OpenXml.Presentation.ApplicationNonVisualDrawingProperties()
),
new DocumentFormat.OpenXml.Presentation.GroupShapeProperties()
)
),
new DocumentFormat.OpenXml.Presentation.ColorMap
{
Background1 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Light1,
Text1 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Dark1,
Background2 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Light2,
Text2 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Dark2,
Accent1 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent1,
Accent2 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent2,
Accent3 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent3,
Accent4 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent4,
Accent5 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent5,
Accent6 = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Accent6,
Hyperlink = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.Hyperlink,
FollowedHyperlink = DocumentFormat.OpenXml.Drawing.ColorSchemeIndexValues.FollowedHyperlink,
},
new DocumentFormat.OpenXml.Presentation.SlideLayoutIdList(
new DocumentFormat.OpenXml.Presentation.SlideLayoutId { Id = 2147483649, RelationshipId = "rId1" },
new DocumentFormat.OpenXml.Presentation.SlideLayoutId { Id = 2147483650, RelationshipId = "rId2" },
new DocumentFormat.OpenXml.Presentation.SlideLayoutId { Id = 2147483651, RelationshipId = "rId3" },
new DocumentFormat.OpenXml.Presentation.SlideLayoutId { Id = 2147483652, RelationshipId = "rId4" },
new DocumentFormat.OpenXml.Presentation.SlideLayoutId { Id = 2147483653, RelationshipId = "rId5" }
)
);
slideMasterPart.SlideMaster.Save();
presentationPart.Presentation = new DocumentFormat.OpenXml.Presentation.Presentation(
new DocumentFormat.OpenXml.Presentation.SlideMasterIdList(
new DocumentFormat.OpenXml.Presentation.SlideMasterId { Id = 2147483648, RelationshipId = "rId1" }
),
new SlideIdList(),
new SlideSize { Cx = (int)SlideSizeDefaults.Widescreen16x9Cx, Cy = (int)SlideSizeDefaults.Widescreen16x9Cy },
new NotesSize { Cx = SlideSizeDefaults.NotesPortraitCx, Cy = SlideSizeDefaults.NotesPortraitCy }
);
presentationPart.Presentation.Save();
OfficeCliMetadata.StampOnCreate(doc);
}
private static Shape CreateLayoutPlaceholder(uint id, string name, PlaceholderValues phType,
long x, long y, long cx, long cy, uint? idx = null)
{
var shape = new Shape();
// OOXML convention (PowerPoint templates): Title/CenteredTitle placeholders
// omit @idx (defaults to 0); SubTitle / Body / Footer / Date / SlideNumber
// slots carry an explicit @idx so slide-side <p:ph idx="N"/> can bind back to
// the matching layout placeholder during inheritance resolution.
var placeholder = new PlaceholderShape { Type = phType };
if (idx.HasValue) placeholder.Index = idx.Value;
shape.NonVisualShapeProperties = new NonVisualShapeProperties(
new NonVisualDrawingProperties { Id = id, Name = name },
new NonVisualShapeDrawingProperties(new DocumentFormat.OpenXml.Drawing.ShapeLocks { NoGrouping = true }),
new ApplicationNonVisualDrawingProperties(placeholder)
);
shape.ShapeProperties = new ShapeProperties(
new DocumentFormat.OpenXml.Drawing.Transform2D(
new DocumentFormat.OpenXml.Drawing.Offset { X = x, Y = y },
new DocumentFormat.OpenXml.Drawing.Extents { Cx = cx, Cy = cy }
)
);
shape.TextBody = new TextBody(
new DocumentFormat.OpenXml.Drawing.BodyProperties(),
new DocumentFormat.OpenXml.Drawing.ListStyle(),
new DocumentFormat.OpenXml.Drawing.Paragraph(
new DocumentFormat.OpenXml.Drawing.EndParagraphRunProperties { Language = "en-US" })
);
return shape;
}
}
+521
View File
@@ -0,0 +1,521 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
using OfficeCli.Help;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildAddCommand(Option<bool> jsonOption)
{
var addFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var addParentPathArg = new Argument<string>("parent")
{
Description = "Parent DOM path. Conventions per handler: docx uses /body (or /body/p[N] for nested adds); xlsx uses /Sheet1 (or any sheet name); pptx slide uses '/' (slides hang off the presentation root), pptx shape uses /slide[N]. Wrap paths containing brackets in single quotes for zsh: '/slide[1]'."
};
var addTypeOpt = new Option<string>("--type") { Description = "Element type to add (e.g. paragraph, run, table, sheet, row, cell, slide, shape, picture, diagram/flowchart, ole, video)" };
var addFromOpt = new Option<string?>("--from") { Description = "Copy from an existing element path (e.g. /slide[1]/shape[2])" };
var addIndexOpt = new Option<int?>("--index")
{
Description = "Insert position (0-based). If omitted, appends to end",
// Strict parser: reject trailing/leading whitespace so "3 " doesn't
// silently succeed while "1.5"/"abc" cleanly error. Mirrors the
// tight parse other invalid numeric inputs already get.
CustomParser = ar =>
{
if (ar.Tokens.Count == 0) return null;
var raw = ar.Tokens[0].Value;
if (raw != raw.Trim() || !int.TryParse(raw, System.Globalization.NumberStyles.AllowLeadingSign, System.Globalization.CultureInfo.InvariantCulture, out var v))
{
ar.AddError($"Cannot parse argument '{raw}' for option '--index' as expected type 'System.Nullable`1[System.Int32]'.");
return null;
}
return v;
}
};
var addAfterOpt = new Option<string?>("--after") { Description = "Insert after the element at this path (e.g. p[@paraId=1A2B3C4D])" };
var addBeforeOpt = new Option<string?>("--before") { Description = "Insert before the element at this path" };
var addPropsOpt = new Option<string[]>("--prop") { Description = "Property to set (key=value, e.g. --prop src=image.png --prop width=6in)", AllowMultipleArgumentsPerToken = true };
var forceOption = new Option<bool>("--force") { Description = "Force write even if document is protected" };
var addCommand = new Command("add", "Add a new element to the document") { TreatUnmatchedTokensAsErrors = false };
addCommand.Add(addFileArg);
addCommand.Add(addParentPathArg);
addCommand.Add(addTypeOpt);
addCommand.Add(addFromOpt);
addCommand.Add(addIndexOpt);
addCommand.Add(addAfterOpt);
addCommand.Add(addBeforeOpt);
addCommand.Add(addPropsOpt);
addCommand.Add(jsonOption);
addCommand.Add(forceOption);
addCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(addFileArg)!;
var parentPath = MsysPathHint.Restore(result.GetValue(addParentPathArg)!)!;
var type = result.GetValue(addTypeOpt);
var from = MsysPathHint.Restore(result.GetValue(addFromOpt));
var index = result.GetValue(addIndexOpt);
var after = MsysPathHint.Restore(result.GetValue(addAfterOpt));
var before = MsysPathHint.Restore(result.GetValue(addBeforeOpt));
var props = result.GetValue(addPropsOpt);
var force = result.GetValue(forceOption);
// Validate mutual exclusivity of --index, --after, --before
var posCount = (index.HasValue ? 1 : 0) + (after != null ? 1 : 0) + (before != null ? 1 : 0);
if (posCount > 1)
throw new OfficeCli.Core.CliException("--index, --after, and --before are mutually exclusive. Use only one.")
{
Code = "invalid_argument",
Suggestion = "Use --index for positional insert, or --after/--before for anchor-based insert."
};
InsertPosition? position = index.HasValue ? InsertPosition.AtIndex(index.Value)
: after != null ? InsertPosition.AfterElement(after)
: before != null ? InsertPosition.BeforeElement(before)
: null;
bool hadWarnings = false;
// Check document protection for .docx files
if (!force && file.Extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
{
var protectionError = CheckDocxProtection(file.FullName, parentPath, json);
if (protectionError != 0) return protectionError;
}
// Detect bare key=value positional arguments (missing --prop)
var unmatchedKvWarnings = DetectUnmatchedKeyValues(result);
if (unmatchedKvWarnings.Count > 0)
{
hadWarnings = true;
if (json)
{
var kvWarnings = unmatchedKvWarnings.Select(kv => new OfficeCli.Core.CliWarning
{
Message = $"Bare property '{kv}' ignored. Use --prop {kv}",
Code = "missing_prop_flag",
Suggestion = $"--prop {kv}"
}).ToList();
Console.Error.WriteLine("WARNING: Properties specified without --prop flag.");
}
else
{
foreach (var kv in unmatchedKvWarnings)
Console.Error.WriteLine($"WARNING: Bare property '{kv}' ignored. Did you mean: --prop {kv}");
Console.Error.WriteLine("Hint: Properties must be passed with --prop flag, e.g. officecli add <file> <parent> --type picture --prop src=image.png");
}
}
// TreatUnmatchedTokensAsErrors=false exists so the bare key=value
// warnings above can fire — but it also let a completely unknown
// `--flag value` pair (e.g. `--at A2`) vanish silently with exit 0,
// placing the element somewhere the caller did not intend. Any
// remaining unmatched --option that DetectUnmatchedKeyValues did
// not claim is a hard error, matching set/get behavior.
RejectUnknownOptionTokens(result, unmatchedKvWarnings);
if (string.IsNullOrEmpty(type) && string.IsNullOrEmpty(from))
{
throw new OfficeCli.Core.CliException("Either --type or --from must be specified.")
{
Code = "missing_argument",
Suggestion = "Use --type to specify element type, or --from to copy an existing element.",
Help = "officecli add <file> <parent> --type <type> --prop src=<file>"
};
}
// BUG(add-from-prop-silently-ignored): --from copies an existing
// element verbatim and does not apply --prop overrides. Reject the
// combination explicitly so users don't think their --prop took
// effect. Workaround: copy first, then `set` the result path.
if (!string.IsNullOrEmpty(from) && props != null && props.Length > 0)
{
throw new OfficeCli.Core.CliException("--prop cannot be combined with --from; use `set` on the copied path to modify properties.")
{
Code = "invalid_argument",
Suggestion = "Run `add --from` first, then `set <new-path> --prop k=v` on the result."
};
}
if (!string.IsNullOrEmpty(from))
{
// Copy from existing element
if (TryResident(file.FullName, req =>
{
req.Command = "add";
req.Args["parent"] = parentPath;
req.Args["from"] = from;
if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
if (position?.After != null) req.Args["after"] = position.After;
if (position?.Before != null) req.Args["before"] = position.Before;
}, json) is {} rc) return rc != 0 ? rc : (hadWarnings ? 2 : 0);
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0;
var resultPath = handler.CopyFrom(from, parentPath, position);
var message = $"Copied to {resultPath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
if (parentPath == "/") NotifyWatchRoot(handler, file.FullName, oldCount);
else NotifyWatch(handler, file.FullName, parentPath);
}
else
{
if (TryResident(file.FullName, req =>
{
req.Command = "add";
req.Args["parent"] = parentPath;
req.Args["type"] = type!;
if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
if (position?.After != null) req.Args["after"] = position.After;
if (position?.Before != null) req.Args["before"] = position.Before;
req.Props = ParsePropsArray(props);
}, json) is {} rc) return rc != 0 ? rc : (hadWarnings ? 2 : 0);
// CONSISTENCY(prop-key-case): --prop keys are case-insensitive
// so "SRC=x" and "src=x" both resolve to the same handler key.
// Reuse ParsePropsArray so the inline and resident-server paths
// stay in sync.
var properties = ParsePropsArray(props);
// ARCHITECTURE(handler-as-truth): the handler is the single
// source of truth for "is this prop supported". We pass the
// user's full prop dict through a TrackingPropertyDictionary
// that records which keys the handler actually reads. Any
// input key the handler never touches is reported as
// unsupported_property afterwards. Replaces the old schema-
// pre-filter that stripped legitimate aliases the handler
// genuinely understood but the schema hadn't enumerated yet.
// CONSISTENCY(schema-prop-validation): same approach mirrored
// in ResidentServer.ExecuteAdd.
var tracking = new OfficeCli.Core.TrackingPropertyDictionary(properties);
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0;
var resultPath = handler.Add(parentPath, type!, position, tracking);
var unsupported = tracking.UnusedKeys.ToList();
// Merge handler-internal unsupported props (handlers that
// iterate the dictionary via foreach can't surface unknowns
// through tracking.UnusedKeys — the enumerator marks every
// key as accessed by design, see
// TrackingPropertyDictionary.cs:24-37. Those handlers write
// bare unknown keys to LastAddUnsupportedProps instead, and
// the ResidentServer path already merges this list — mirror
// that here so the non-resident CLI path surfaces the same
// warnings).
if (handler is OfficeCli.Handlers.WordHandler wordAddH)
unsupported.AddRange(wordAddH.LastAddUnsupportedProps);
var message = $"Added {type!.ToLowerInvariant()} at {resultPath}";
var spatialLine = GetPptSpatialLine(handler, resultPath);
var overlapNames = spatialLine != null ? CheckPositionOverlap(handler, resultPath) : new();
var addWarnings = new List<OfficeCli.Core.CliWarning>();
if (overlapNames.Count > 0)
{
addWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"Same position as {string.Join(", ", overlapNames)}",
Code = "position_overlap",
Suggestion = "Use --prop x=... y=... to set distinct positions"
});
}
var addOverflow = CheckTextOverflow(handler, resultPath);
if (addOverflow != null)
{
addWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = addOverflow,
Code = "text_overflow",
Suggestion = "Increase shape height/width, reduce font size, or shorten text"
});
}
// Map suggestion scope off the handler type — same pattern as
// CommandBuilder.Set.cs so Excel adds don't get PPT-only
// suggestion noise.
string? addSuggestionScope = handler switch
{
OfficeCli.Handlers.ExcelHandler => "excel",
OfficeCli.Handlers.WordHandler => "word",
OfficeCli.Handlers.PowerPointHandler => "pptx",
_ => null,
};
foreach (var u in unsupported)
{
var suggestion = SuggestPropertyScoped(u, addSuggestionScope);
addWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = suggestion != null
? $"Unsupported property: {u} (did you mean: {suggestion}?)"
: $"Unsupported property: {u}",
Code = "unsupported_property",
Suggestion = suggestion,
});
}
// Unrecognized LaTeX commands/environments from an equation
// parse. Surfaced with the same UX as unsupported_property
// (warning + JSON envelope + exit 2) — the equation is still
// written (lenient accept), but the literal-text fallback is no
// longer silent. CONSISTENCY: mirrored in ResidentServer.ExecuteAdd.
var unrecognizedLatex = handler switch
{
OfficeCli.Handlers.WordHandler wlx => wlx.LastUnrecognizedLatex,
OfficeCli.Handlers.PowerPointHandler plx => plx.LastUnrecognizedLatex,
_ => null,
};
if (unrecognizedLatex is { Count: > 0 })
{
foreach (var tok in unrecognizedLatex)
{
addWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"unrecognized_latex_command: {tok}",
Code = "unrecognized_latex_command",
Suggestion = "Check the command spelling; see https://katex.org/docs/supported.html for supported syntax.",
});
}
}
// Advisory warnings from the Word handler (e.g. unknown styleId
// referenced as-is, unresolved styleName with spaces skipped).
// These do NOT flip the exit code: the requested value was still
// written (the styleId is stored as-is), so the mutation
// succeeded — exit 0 with the warning on stderr, mirroring Set's
// identical "style '…' not found — referenced as-is" path. Exit
// is reserved for "the value did not get written" (unsupported
// property below → 2; missing element → not_found).
if (handler is OfficeCli.Handlers.WordHandler addWhWarn
&& addWhWarn.LastAddWarnings.Count > 0)
{
foreach (var w in addWhWarn.LastAddWarnings)
{
addWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = w,
Code = "advisory",
});
}
}
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelopeText(
spatialLine != null ? $"{message}\n {spatialLine}" : message,
addWarnings.Count > 0 ? addWarnings : null));
}
else
{
Console.WriteLine(message);
if (spatialLine != null) Console.WriteLine($" {spatialLine}");
foreach (var w in addWarnings)
{
if (w.Code == "unsupported_property") continue; // emitted as UNSUPPORTED line below
Console.Error.WriteLine($" WARNING: {w.Message}");
}
if (unsupported.Count > 0)
Console.Error.WriteLine(FormatUnsupported(unsupported, addSuggestionScope));
}
if (parentPath == "/") NotifyWatchRoot(handler, file.FullName, oldCount);
else NotifyWatch(handler, file.FullName, parentPath);
if (unsupported.Count > 0) return 2;
if (unrecognizedLatex is { Count: > 0 }) return 2;
}
return hadWarnings ? 2 : 0;
}, json); });
return addCommand;
}
private static Command BuildRemoveCommand(Option<bool> jsonOption)
{
var removeFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var removePathArg = new Argument<string>("path") { Description = "DOM path of the element to remove" };
var shiftOption = new Option<string?>("--shift") {
Description = "(Excel cell only) Shift surrounding cells to fill the gap: left | up. " +
"For full row/col delete with metadata adjustments, target the row/col path directly."
};
var removePropsOpt = new Option<string[]>("--prop") {
Description = "Modifier property (key=value). Phase 4: --prop trackChange.author=<name> on a Word Run or Paragraph path records a w:del revision instead of physically deleting.",
AllowMultipleArgumentsPerToken = true,
};
var removeCommand = new Command("remove", "Remove an element from the document");
removeCommand.Add(removeFileArg);
removeCommand.Add(removePathArg);
removeCommand.Add(shiftOption);
removeCommand.Add(removePropsOpt);
removeCommand.Add(jsonOption);
removeCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(removeFileArg)!;
var path = MsysPathHint.Restore(result.GetValue(removePathArg)!)!;
var shift = result.GetValue(shiftOption);
var props = result.GetValue(removePropsOpt);
var parsedProps = (props != null && props.Length > 0) ? ParsePropsArray(props) : null;
// Agent-safety: reject a bare unscoped selector (`run`, `shape[...]`) —
// a bare `remove "run"` would delete every run. Allows `/`-scoped paths
// and Excel `Sheet1!A1`. Runs before TryResident so the resident path
// is guarded too.
OfficeCli.Core.MutationSelectorGuard.EnsureScoped(path, "remove");
if (TryResident(file.FullName, req =>
{
req.Command = "remove";
req.Args["path"] = path;
if (!string.IsNullOrEmpty(shift)) req.Args["shift"] = shift;
if (parsedProps != null) req.Props = parsedProps;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var oldCount = (handler as OfficeCli.Handlers.PowerPointHandler)?.GetSlideCount() ?? 0;
string? warning;
if (!string.IsNullOrEmpty(shift))
{
if (handler is not OfficeCli.Handlers.ExcelHandler xlHandler)
throw new OfficeCli.Core.CliException(
"--shift is supported only for Excel cell paths (e.g. /Sheet1/B5).")
{ Code = "invalid_argument" };
warning = xlHandler.RemoveCellWithShift(path, shift);
}
else
{
warning = handler.Remove(path, parsedProps);
}
var message = $"Removed {path}";
if (warning != null) message += $"\n{warning}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
var slideNum = WatchMessage.ExtractSlideNum(path);
if (slideNum > 0 && !path.Contains("/shape["))
NotifyWatchRoot(handler, file.FullName, oldCount);
else
NotifyWatch(handler, file.FullName, path);
return 0;
}, json); });
return removeCommand;
}
private static Command BuildMoveCommand(Option<bool> jsonOption)
{
var moveFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var movePathArg = new Argument<string>("path") { Description = "DOM path of the element to move" };
var moveToOpt = new Option<string?>("--to") { Description = "Target parent path. If omitted, reorders within the current parent" };
var moveIndexOpt = new Option<int?>("--index") { Description = "Insert position (0-based). If omitted, appends to end" };
var moveAfterOpt = new Option<string?>("--after") { Description = "Move after the element at this path" };
var moveBeforeOpt = new Option<string?>("--before") { Description = "Move before the element at this path" };
// --prop currently carries trackChange.author/date/id for the
// run-level move-tracking branch in WordHandler. Other handlers
// (xlsx/pptx) accept the option for parity but ignore the values.
var movePropsOpt = new Option<string[]>("--prop") { Description = "Property to set on the move (e.g. --prop trackChange.author=Alice for tracked moves)", AllowMultipleArgumentsPerToken = true };
var moveCommand = new Command("move", "Move an element to a new position or parent");
moveCommand.Add(moveFileArg);
moveCommand.Add(movePathArg);
moveCommand.Add(moveToOpt);
moveCommand.Add(moveIndexOpt);
moveCommand.Add(moveAfterOpt);
moveCommand.Add(moveBeforeOpt);
moveCommand.Add(movePropsOpt);
moveCommand.Add(jsonOption);
moveCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(moveFileArg)!;
var path = MsysPathHint.Restore(result.GetValue(movePathArg)!)!;
var to = MsysPathHint.Restore(result.GetValue(moveToOpt));
var index = result.GetValue(moveIndexOpt);
var after = MsysPathHint.Restore(result.GetValue(moveAfterOpt));
var before = MsysPathHint.Restore(result.GetValue(moveBeforeOpt));
var props = result.GetValue(movePropsOpt);
// Validate mutual exclusivity of --index, --after, --before
var posCount = (index.HasValue ? 1 : 0) + (after != null ? 1 : 0) + (before != null ? 1 : 0);
if (posCount > 1)
throw new OfficeCli.Core.CliException("--index, --after, and --before are mutually exclusive. Use only one.")
{
Code = "invalid_argument",
Suggestion = "Use --index for positional insert, or --after/--before for anchor-based insert."
};
InsertPosition? position = index.HasValue ? InsertPosition.AtIndex(index.Value)
: after != null ? InsertPosition.AfterElement(after)
: before != null ? InsertPosition.BeforeElement(before)
: null;
var moveProps = ParsePropsArray(props);
if (TryResident(file.FullName, req =>
{
req.Command = "move";
req.Args["path"] = path;
if (to != null) req.Args["to"] = to;
if (position?.Index.HasValue == true) req.Args["index"] = position.Index.Value.ToString();
if (position?.After != null) req.Args["after"] = position.After;
if (position?.Before != null) req.Args["before"] = position.Before;
if (moveProps.Count > 0) req.Props = moveProps;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var resultPath = handler.Move(path, to, position, moveProps.Count > 0 ? moveProps : null);
var message = $"Moved to {resultPath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
NotifyWatch(handler, file.FullName, path);
return 0;
}, json); });
return moveCommand;
}
private static Command BuildSwapCommand(Option<bool> jsonOption)
{
var swapFileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var swapPath1Arg = new Argument<string>("path1") { Description = "DOM path of the first element" };
var swapPath2Arg = new Argument<string>("path2") { Description = "DOM path of the second element" };
var swapCommand = new Command("swap", "Swap two elements in the document");
swapCommand.Add(swapFileArg);
swapCommand.Add(swapPath1Arg);
swapCommand.Add(swapPath2Arg);
swapCommand.Add(jsonOption);
swapCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(swapFileArg)!;
var path1 = MsysPathHint.Restore(result.GetValue(swapPath1Arg)!)!;
var path2 = MsysPathHint.Restore(result.GetValue(swapPath2Arg)!)!;
if (TryResident(file.FullName, req =>
{
req.Command = "swap";
req.Args["path"] = path1;
req.Args["to"] = path2;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var (p1, p2) = handler switch
{
OfficeCli.Handlers.PowerPointHandler ppt => ppt.Swap(path1, path2),
OfficeCli.Handlers.WordHandler word => word.Swap(path1, path2),
OfficeCli.Handlers.ExcelHandler excel => excel.Swap(path1, path2),
_ => throw new InvalidOperationException("swap not supported for this document type")
};
var message = $"Swapped {p1} <-> {p2}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message));
else Console.WriteLine(message);
NotifyWatch(handler, file.FullName, path1);
return 0;
}, json); });
return swapCommand;
}
}
+481
View File
@@ -0,0 +1,481 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
// Shown as the `batch` command's help/description. The flags alone don't
// tell a caller (or an agent) what each JSON array ITEM looks like — the
// single most common batch mistake is stuffing a whole CLI line into
// "command" (e.g. {"command":"add /slide[1] --type shape --prop ..."}),
// which fails with "Unknown command". Document the per-item shape and a
// concrete example here so `help batch` actually teaches it.
private const string BatchHelpDescription =
"Execute multiple commands from a JSON array in a single pass. Standalone, this is one open/save cycle; through a live resident the items apply in memory and the disk write is deferred to save/close/idle-autosave — adaptive 2-10s after going idle, or before the batch returns under OFFICECLI_RESIDENT_FLUSH=each (officecli's own reads still see the changes immediately).\n\n"
+ "Each array item is an OBJECT whose \"command\" is the bare verb "
+ "(add/set/remove/move/swap/get/query/...); the verb's arguments are SIBLING fields, "
+ "not a CLI string inside \"command\". Common fields: \"parent\" (add target), "
+ "\"path\" (set/remove/get target), \"selector\" (query filter; \"path\" is accepted as an alias), \"type\" (element type for add), "
+ "\"props\" (a key->value map of --prop values), \"to\"/\"after\"/\"before\" (move), "
+ "\"path2\" (swap's second path).\n\n"
+ "Pass the array via --commands, or as the same JSON on stdin / --input <file>. Example:\n"
+ "[\n"
+ " {\"command\":\"add\",\"parent\":\"/slide[1]\",\"type\":\"shape\",\"props\":{\"text\":\"Hi\",\"x\":\"1cm\",\"y\":\"2cm\"}},\n"
+ " {\"command\":\"set\",\"path\":\"/slide[1]/shape[1]\",\"props\":{\"bold\":\"true\"}},\n"
+ " {\"command\":\"remove\",\"path\":\"/slide[2]/shape[3]\"}\n"
+ "]";
/// <summary>
/// Apply a batch of commands against an already-open handler. This is the
/// single shared replay loop behind all three batch surfaces — the
/// non-resident CLI path, the MCP server, and the resident server — so the
/// try/catch/stop-on-error semantics can never drift between them again.
///
/// Save deferral and protection gating are intentionally NOT done here:
/// they differ by handler lifetime. The dispose-based callers (CLI
/// non-resident, MCP) leave <c>DeferSave=true</c> and rely on the
/// Dispose-time <c>FinalizeDeferredIds</c> flush — see
/// <see cref="RunNonResidentBatch"/>; the long-lived resident saves and
/// restores <c>DeferSave</c> and runs <c>ReconcileGlobalIds</c> itself.
///
/// <paramref name="skipResidentOnlyCommands"/> is set by the resident, which
/// already holds the file open: an <c>open</c>/<c>close</c> inside the batch
/// would conflict, so they are reported as skipped instead of executed.
/// </summary>
internal static List<BatchResult> ApplyBatchItems(
OfficeCli.Core.IDocumentHandler handler, List<BatchItem> items,
bool stopOnError, bool json, bool skipResidentOnlyCommands = false,
ICollection<string>? unrecognizedLatex = null)
{
var results = new List<BatchResult>();
for (int bi = 0; bi < items.Count; bi++)
{
var item = items[bi];
if (skipResidentOnlyCommands)
{
var cmd = (item.Command ?? "").ToLowerInvariant();
if (cmd is "open" or "close")
{
results.Add(new BatchResult { Index = bi, Success = true, Output = $"Skipped '{cmd}' (resident mode)" });
continue;
}
}
try
{
var output = ExecuteBatchItem(handler, item, json);
results.Add(new BatchResult { Index = bi, Success = true, Output = output });
}
catch (Exception ex)
{
results.Add(new BatchResult { Index = bi, Success = false, Item = item, Error = ex.Message });
if (stopOnError) break;
}
// BUG-BT2: per-item unrecognized-LaTeX diagnostics. The handler
// resets LastUnrecognizedLatex on every Add/Set, so its tokens are
// only valid for the item just executed — collect them now,
// de-duplicated, so the caller can surface the same
// unrecognized_latex_command warning (and exit 2) that single-shot
// add/set produce. Without this the warnings were silently
// swallowed by the batch path.
if (unrecognizedLatex != null)
{
var toks = handler switch
{
OfficeCli.Handlers.WordHandler wlx => wlx.LastUnrecognizedLatex,
OfficeCli.Handlers.PowerPointHandler plx => plx.LastUnrecognizedLatex,
_ => null,
};
if (toks is { Count: > 0 })
foreach (var t in toks)
if (!unrecognizedLatex.Contains(t)) unrecognizedLatex.Add(t);
}
}
return results;
}
/// <summary>
/// Run a batch against a freshly-opened, dispose-on-return handler (the
/// non-resident CLI path and the MCP server). Sets <c>DeferSave</c> so the
/// N commands serialize the part once at Dispose instead of N times — the
/// O(N²) re-serialize that dominates large replays. The handler is NOT
/// disposed here; the caller's <c>using</c> performs the single
/// <c>FinalizeDeferredIds + Save</c> flush. Output formatting and the
/// protection gate stay with the caller (their surfaces differ).
/// </summary>
internal static List<BatchResult> RunNonResidentBatch(
OfficeCli.Core.IDocumentHandler handler, List<BatchItem> items,
bool stopOnError, bool json, ICollection<string>? unrecognizedLatex = null)
{
if (handler is OfficeCli.Handlers.WordHandler wh) wh.DeferSave = true;
return ApplyBatchItems(handler, items, stopOnError, json, unrecognizedLatex: unrecognizedLatex);
}
private static Command BuildBatchCommand(Option<bool> jsonOption)
{
var batchFileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var batchInputOpt = new Option<FileInfo?>("--input") { Description = "JSON file containing batch commands. If omitted, reads from stdin" };
var batchCommandsOpt = new Option<string?>("--commands") { Description = "Inline JSON array of batch commands (alternative to --input or stdin)" };
// BUG-R4-BT2: default flipped to continue-on-error. A 700-command
// dump replay losing 80% of the document on the first failing item
// (e.g. one unsupported prop) is a far worse default than reporting
// the failure and letting the rest of the batch through. Errors are
// still surfaced individually (BatchResult.Error) and the overall
// exit code is 1 if any item failed, so callers can still tell
// "everything succeeded". `--stop-on-error` opts back into the
// strict abort-on-first-failure flow for callers who depend on it.
var batchForceOpt = new Option<bool>("--force") { Description = "Deprecated alias for the default continue-on-error mode (kept for compatibility)" };
var batchStopOpt = new Option<bool>("--stop-on-error") { Description = "Abort the batch as soon as any command fails (default: continue and report per-item errors)" };
var batchCommand = new Command("batch", BatchHelpDescription);
batchCommand.Add(batchFileArg);
batchCommand.Add(batchInputOpt);
batchCommand.Add(batchCommandsOpt);
batchCommand.Add(batchForceOpt);
batchCommand.Add(batchStopOpt);
batchCommand.Add(jsonOption);
batchCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(batchFileArg)!;
var inputFile = result.GetValue(batchInputOpt);
var inlineCommands = result.GetValue(batchCommandsOpt);
// Default: continue on error. --stop-on-error flips it to strict.
// --force still acts as the docx-protection bypass (matches set
// --force semantics) but no longer doubles as the continue-on-
// error switch.
var stopOnError = result.GetValue(batchStopOpt);
var forceFlag = result.GetValue(batchForceOpt);
string jsonText;
// BUG-R7-09 (F-6): previously --commands/--input/stdin were
// silently prioritized in that order — passing two of them at
// once dropped the lower-priority source with no warning, so
// scripts could fail subtly when an agent piped data into a
// command that already had --commands set. Reject the
// combination loudly. (Detect stdin via Console.IsInputRedirected
// to avoid spurious failures from interactive terminals.)
// IsInputRedirected alone is true for every non-interactive
// invocation (cron, CI, `< /dev/null`, systemd), so the warning
// below fired on effectively all scripted batch runs with only
// one source supplied. Refine: a seekable stdin (regular file or
// /dev/null redirect) with zero length carries no second payload
// — skip the warning. Pipes (CanSeek=false) still warn: someone
// is actively piping data that will be ignored.
bool stdinHasInput = Console.IsInputRedirected;
if (stdinHasInput)
{
// Peek with a short timeout: /dev/null and closed stdin hit
// EOF instantly (no payload → no warning); a pipe carrying a
// real second payload has data ready (warn); an open-but-idle
// pipe times out and is treated as no payload — batch never
// reads stdin on this path anyway, so nothing is lost. The
// possibly-blocked Peek thread is abandoned; the process
// exits normally.
try
{
var stdinPeek = System.Threading.Tasks.Task.Run(() =>
{
try { return Console.In.Peek() != -1; }
catch { return false; }
});
stdinHasInput = stdinPeek.Wait(TimeSpan.FromMilliseconds(50)) && stdinPeek.Result;
}
catch { /* keep IsInputRedirected verdict */ }
}
if (inlineCommands != null && inputFile != null)
throw new ArgumentException(
"batch: --commands and --input are mutually exclusive. Pick one source.");
// '--input -' explicitly opts INTO stdin — don't emit the
// "stdin will be ignored" warning in that case, since stdin
// is exactly what will be read.
var inputIsStdinAlias = inputFile != null && inputFile.Name == "-";
if ((inlineCommands != null || (inputFile != null && !inputIsStdinAlias)) && stdinHasInput
&& Environment.GetEnvironmentVariable("OFFICECLI_BATCH_ALLOW_STDIN_REDIRECT") == null)
{
Console.Error.WriteLine(
"Warning: batch is reading from --commands/--input but stdin is also redirected; "
+ "stdin will be ignored. Pass only one source to silence this warning, or set "
+ "OFFICECLI_BATCH_ALLOW_STDIN_REDIRECT=1.");
}
if (inlineCommands != null)
{
jsonText = inlineCommands;
}
else if (inputFile != null)
{
// Accept the conventional Unix '-' alias for stdin so
// pipelines like `cat ops.json | officecli batch foo.pptx --input -`
// don't have to drop --input entirely. Matches the implicit
// "no --input ⇒ read stdin" branch below; using --input -
// makes the intent explicit instead of relying on the
// absent-flag default. (TargetMode/Exists checks are
// skipped on purpose — '-' is not a path.)
if (inputFile.Name == "-")
{
jsonText = StripBom(Console.In.ReadToEnd());
}
else
{
if (!inputFile.Exists)
{
throw new FileNotFoundException($"Input file not found: {inputFile.FullName}");
}
jsonText = File.ReadAllText(inputFile.FullName);
}
}
else
{
// Read from stdin. File.ReadAllText auto-detects and strips
// UTF-8 BOM; Console.In does not. Without an explicit strip,
// `cat utf8bom.json | officecli batch foo.pptx` failed
// System.Text.Json.Parse with "'' is an invalid start of
// a value" while `batch --input utf8bom.json` succeeded —
// splitting the contract on the input source.
jsonText = StripBom(Console.In.ReadToEnd());
}
// Pre-validate: check for unknown JSON fields before deserializing
var jsonDoc = System.Text.Json.JsonDocument.Parse(jsonText);
// CONSISTENCY(dump-batch-pipeline): `dump --json` wraps the
// BatchItem array in an envelope object (`{"success":true,
// "data":[…]}` via OutputFormatter.WrapEnvelope). The natural
// pipeline `dump --json > out.json && batch --input out.json`
// otherwise threw "Batch input must be a JSON array" because the
// root is an object. Auto-unwrap when the envelope has a `data`
// array so the pipeline just works without an extra `jq .data`
// step. Bare-array inputs (dump without --json) are unaffected.
if (jsonDoc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object
&& jsonDoc.RootElement.TryGetProperty("data", out var envData)
&& envData.ValueKind == System.Text.Json.JsonValueKind.Array)
{
jsonText = envData.GetRawText();
jsonDoc.Dispose();
jsonDoc = System.Text.Json.JsonDocument.Parse(jsonText);
}
using var _jsonDocOwner = jsonDoc;
var rootKind = jsonDoc.RootElement.ValueKind;
if (rootKind != System.Text.Json.JsonValueKind.Array
&& rootKind != System.Text.Json.JsonValueKind.Null)
{
// BUG-R7-10: when the batch input is a JSON object/string/etc.
// (not an array), Deserialize<List<BatchItem>> threw a generic
// JsonException whose message exposed the C# generic type name
// (`System.Collections.Generic.List`1[OfficeCli.BatchItem]`).
// Convert it to a human-friendly error first so AI agents and
// humans see a stable, model-agnostic diagnostic.
throw new ArgumentException(
$"Batch input must be a JSON array. Got: {rootKind.ToString().ToLowerInvariant()}. "
+ "Wrap a single item like [{\"command\":\"get\",\"path\":\"/\"}].");
}
if (jsonDoc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
int ri = 0;
foreach (var elem in jsonDoc.RootElement.EnumerateArray())
{
if (elem.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var unknown = new List<string>();
foreach (var prop in elem.EnumerateObject())
{
if (!BatchItem.KnownFields.Contains(prop.Name))
unknown.Add(prop.Name);
}
if (unknown.Count > 0)
throw new ArgumentException($"batch item[{ri}]: unknown field(s) {string.Join(", ", unknown.Select(f => $"\"{f}\""))}. Valid fields: {string.Join(", ", BatchItem.KnownFields)}");
}
ri++;
}
}
var items = System.Text.Json.JsonSerializer.Deserialize<List<BatchItem>>(jsonText, BatchJsonContext.Default.ListBatchItem) ?? new();
// BUG-R40-B11: explicit null entries (e.g. `[null]`) deserialize
// to a List<BatchItem> with a null slot and trip a NRE deeper in
// ExecuteBatchItem. Reject up-front with a recognizable error
// pointing at the offending index.
for (int ni = 0; ni < items.Count; ni++)
{
if (items[ni] == null)
throw new ArgumentException(
$"batch item[{ni}] is null. Each entry must be a JSON object (e.g. {{\"command\":\"get\",\"path\":\"/\"}}).");
}
if (items.Count == 0)
{
// BUG-R6-07: empty command array previously short-circuited
// before the file-existence check, so
// officecli batch /missing.docx --commands '[]' --json
// returned a clean zero-result success instead of the
// expected file_not_found. Validate the target file
// exists first so empty-array semantics match the
// non-empty path's diagnostics.
if (!file.Exists)
throw new CliException($"File not found: {file.FullName}")
{ Code = "file_not_found" };
// BUG-R7-09: in --json mode an empty/null batch input
// previously skipped the {"success":...,"data":{...}}
// envelope used by the populated-array path, so AI agents
// saw a missing `success` key. Apply the same envelope
// wrap here for shape parity.
if (json)
{
using var sw = new System.IO.StringWriter();
PrintBatchResults(new List<BatchResult>(), json, 0, sw);
var inner = sw.ToString().TrimEnd('\n', '\r');
Console.WriteLine(OfficeCli.Core.OutputFormatter.WrapEnvelope(inner));
}
else
{
PrintBatchResults(new List<BatchResult>(), json, 0);
}
return 0;
}
// BUG-FUZZER-R6-03: batch must honour the same .docx document
// protection check that `set` enforces. Without this, a protected
// doc could be silently modified via
// officecli batch protected.docx --commands '[{"command":"set",...}]'
// even though the same set issued via the standalone `set` command
// would be rejected. We piggy-back on `--force` (which already
// means "ignore safety guards" for the continue-on-error path) so
// agents that need to override protection use the same flag they
// already know from `set --force`.
// CONSISTENCY(docx-protection): if you change the protection
// semantics, also update CommandBuilder.Set.cs at the matching
// CheckDocxProtection call site.
var force = forceFlag;
// Document protection is gated ONCE per batch against the in-memory
// DOM, not by reopening the file per item (the old per-item loop did
// N full document opens — ~10s of I/O for a 16k batch). The check
// runs where the live tree is available: the resident server checks
// its in-memory _handler (see ExecuteBatch), and the non-resident
// path checks just after it opens the handler (below). Reading the
// live tree — not the on-disk copy — also keeps the gate correct
// when a resident holds uncommitted in-memory protection changes
// (resident sessions flush only on save/close/idle-autosave).
// If a resident process is running, send the entire batch as a
// single "batch" command so all items run in one pass inside the
// resident. NOTE: unlike the standalone path, this does NOT save to
// disk per batch — the resident applies the items in memory and
// defers the flush to the next save/close/idle-autosave. A reader
// that bypasses the resident must flush first (see command-open /
// command-batch wiki "Persisting changes").
if (ResidentClient.TryConnect(file.FullName, out _))
{
var req = new ResidentRequest
{
Command = "batch",
Json = json,
Args =
{
["batchJson"] = jsonText,
["force"] = force.ToString(),
["stopOnError"] = stopOnError.ToString()
}
};
// CONSISTENCY(resident-two-step): long connectTimeoutMs so the
// batch waits for its turn in the main-pipe queue instead of
// silently timing out under load. Matches TryResident in
// CommandBuilder.cs.
var response = ResidentClient.TrySend(file.FullName, req, maxRetries: 3, connectTimeoutMs: 30000);
if (response == null)
{
Console.Error.WriteLine($"Resident for {file.Name} is running but the batch could not be delivered (main pipe busy or unresponsive). Retry, or run 'officecli close {file.Name}' and try again.");
return 3;
}
// The resident returns the formatted batch output directly
if (!string.IsNullOrEmpty(response.Stdout))
Console.Write(response.Stdout);
if (!string.IsNullOrEmpty(response.Stderr))
Console.Error.Write(response.Stderr);
return response.ExitCode;
}
// Non-resident: open file once, execute all commands, save once.
// Defer per-mutation Document.Save() so N commands serialize the
// part once (at Dispose) instead of N times — eliminates an O(N²)
// re-serialize that dominates large replays. Save-once is the
// documented intent of this path; per-op Save was redundant given
// the Dispose-time flush.
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
// Protection gate against the just-opened in-memory DOM (one check
// for the whole batch; no second file open).
if (!force && file.Extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
{
var protBlock = GetBatchProtectionBlock(handler, items);
if (protBlock != null)
{
if (json)
Console.WriteLine(OfficeCli.Core.OutputFormatter.WrapEnvelopeError(protBlock, new List<OfficeCli.Core.CliWarning>()));
else
Console.Error.WriteLine($"ERROR: {protBlock}");
return 1;
}
}
// DeferSave + replay loop, shared with the MCP batch surface. The
// handler's using-Dispose performs the single FinalizeDeferredIds +
// Save flush.
var batchUnrecognizedLatex = new List<string>();
var batchResults = RunNonResidentBatch(handler, items, stopOnError, json, batchUnrecognizedLatex);
// BUG-R6-02: in --json mode the non-resident path emitted the raw
// {"results":...,"summary":...} body while the resident path
// wrapped it in {"success":..., "data":{...}} (resident server
// calls OutputFormatter.WrapEnvelope on any JSON-shaped stdout).
// Capture PrintBatchResults output and apply the same envelope
// here so callers see the same shape regardless of resident state.
// JSON Envelope contract: batch is a *judgment* command (root
// the project conventions "Judgment: any batch step failed -> outer false").
// Outer envelope.success is true only when every step succeeded;
// a single failed step flips outer to false even if siblings
// succeeded. Per-step verdicts still ride on
// `data.results[].success`. Exit code stays in lockstep with
// envelope.success so CI gates and shells can rely on the single
// signal. Two `success` fields appear in the JSON (outer batch
// verdict, inner per-step) — disambiguate by JSON path.
var batchSuccess = batchResults.Count == 0 || !batchResults.Any(r => !r.Success);
// BUG-BT2: surface per-item unrecognized-LaTeX warnings the same way
// single-shot add/set do (warning + JSON envelope + exit 2). A batch
// whose only issue is an unknown LaTeX command otherwise exited 0
// with no diagnostic, silently writing the literal-text fallback.
var batchWarnings = new List<OfficeCli.Core.CliWarning>();
foreach (var tok in batchUnrecognizedLatex)
{
batchWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"unrecognized_latex_command: {tok}",
Code = "unrecognized_latex_command",
Suggestion = "Check the command spelling; see https://katex.org/docs/supported.html for supported syntax.",
});
}
if (json)
{
using var sw = new System.IO.StringWriter();
PrintBatchResults(batchResults, json, items.Count, sw);
var inner = sw.ToString().TrimEnd('\n', '\r');
Console.WriteLine(OfficeCli.Core.OutputFormatter.WrapEnvelope(inner, batchWarnings, success: batchSuccess));
}
else
{
PrintBatchResults(batchResults, json, items.Count);
foreach (var w in batchWarnings)
Console.Error.WriteLine($" WARNING: {w.Message}");
}
if (batchResults.Any(r => r.Success))
NotifyWatch(handler, file.FullName, null);
// Exit precedence: a failed item (exit 1) outranks an
// unrecognized-LaTeX-only warning (exit 2 mirrors single-shot).
if (!batchSuccess) return 1;
return batchWarnings.Count > 0 ? 2 : 0;
}, json); });
return batchCommand;
}
// UTF-8 BOM trim. File.ReadAllText handles this implicitly via
// StreamReader's detect-encoding; Console.In feeds raw chars.
private static string StripBom(string s)
=> !string.IsNullOrEmpty(s) && s[0] == '' ? s.Substring(1) : s;
}
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildValidateCommand(Option<bool> jsonOption)
{
var validateFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var validateCommand = new Command("validate", "Validate document against OpenXML schema");
validateCommand.Add(validateFileArg);
validateCommand.Add(jsonOption);
validateCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(validateFileArg)!;
if (TryResident(file.FullName, req =>
{
req.Command = "validate";
req.Json = json;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName);
var errors = handler.Validate();
if (json)
{
var validationJson = FormatValidationErrors(errors);
// JSON Envelope contract: validate is a *judgment* command —
// schema errors mean the document failed validation, so the
// envelope must reflect that on success. exit code already
// mirrors this at line below.
Console.WriteLine(OutputFormatter.WrapEnvelope(validationJson, success: errors.Count == 0));
}
else
{
if (errors.Count == 0)
{
Console.WriteLine("Validation passed: no errors found.");
}
else
{
// R7-bt-4: schema validation reports go to stderr —
// callers piping `validate` for CI gates need to see
// the failure summary on the diagnostic stream rather
// than mixed into stdout. Mirrors the resident path.
Console.Error.WriteLine($"Found {errors.Count} validation error(s):");
foreach (var err in errors)
{
Console.Error.WriteLine($" [{err.ErrorType}] {err.Description}");
if (err.Path != null) Console.Error.WriteLine($" Path: {err.Path}");
if (err.Part != null) Console.Error.WriteLine($" Part: {err.Part}");
}
}
}
return errors.Count > 0 ? 1 : 0;
}, json); });
return validateCommand;
}
}
+235
View File
@@ -0,0 +1,235 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using System.Text.Json;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildDumpCommand(Option<bool> jsonOption)
{
var dumpFileArg = new Argument<FileInfo>("file") { Description = "Office document path (.docx, .pptx, or .xlsx)" };
var dumpPathArg = new Argument<string>("path")
{
Description = "DOM path of the subtree to dump. Defaults to '/' (whole document) when omitted. "
+ "Supported docx subtree paths: /, /body, /body/p[N], /body/tbl[N], /theme, /settings, /numbering, /styles. "
+ "Supported pptx subtree paths: /, /presentation, /slide[N], /theme, /notesMaster, /slideMaster[N], /slideLayout[N], /noteSlide[N]. "
+ "Supported xlsx subtree paths: /, /SheetName, /sheet[N]. "
+ "Subtree dumps do NOT include resources at sibling paths (styles/numbering/theme; pptx: master/layout/theme; xlsx: workbook settings/named ranges); replay target must already define referenced styles/numIds/layouts.",
DefaultValueFactory = _ => "/"
};
var formatOpt = new Option<string>("--format")
{
Description = "Output format (currently: batch)",
DefaultValueFactory = _ => "batch"
};
var outOpt = new Option<string?>("--out", "-o") { Description = "Write output to a file instead of stdout" };
var dumpCommand = new Command("dump", "Serialize a document subtree into a replayable batch script (round-trip mechanism)");
dumpCommand.Add(dumpFileArg);
dumpCommand.Add(dumpPathArg);
dumpCommand.Add(formatOpt);
dumpCommand.Add(outOpt);
dumpCommand.Add(jsonOption);
dumpCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(dumpFileArg)!;
var path = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(dumpPathArg)) ?? "/";
var format = (result.GetValue(formatOpt) ?? "batch").ToLowerInvariant();
var outPath = result.GetValue(outOpt);
if (format != "batch")
throw new CliException($"Unsupported --format: {format}. Valid: batch")
{ Code = "invalid_format", ValidValues = ["batch"] };
var ext = Path.GetExtension(file.FullName).ToLowerInvariant();
if (ext != ".docx" && ext != ".pptx" && ext != ".xlsx")
throw new CliException($"dump currently supports .docx, .pptx and .xlsx (got {ext})")
{ Code = "unsupported_format" };
// CONSISTENCY(file-not-found): mirror the get/set/query format —
// "File not found: <path>. Use 'officecli create <path>' to create a
// blank document, or check the file extension.". Without this
// early guard the dump path falls through to the SDK opener whose
// raw '.NET Could not find file' message disagrees with every
// other command and skips the actionable suggestion.
if (!File.Exists(file.FullName))
throw new CliException(
$"File not found: {file.FullName}. " +
$"Use 'officecli create {file.FullName}' to create a blank document, " +
$"or check the file extension.")
{ Code = "file_not_found" };
// BUG-DUMP-R6-01: route through the resident if one holds the file.
// Without this, dump opens its own handler and collides with
// the resident's lock ("file being used by another process").
// Mirrors the TryResident calls in `get`/`query`/`set`.
if (TryResident(file.FullName, req =>
{
req.Command = "dump";
req.Json = json;
req.Args["path"] = path;
req.Args["format"] = format;
if (!string.IsNullOrEmpty(outPath)) req.Args["out"] = outPath!;
}, json) is {} rc) return rc;
// CONSISTENCY(dump-format-dispatch): mirrors docx vs pptx branch
List<BatchItem> items;
List<CliWarning>? dumpWarnings = null;
// CONSISTENCY(dump-text-clean-output): warnings go to stderr in
// --json mode, OR in text mode when the batch JSON is written to a
// file (`--out <file>`, not stdout). The original suppression
// existed because text-mode `dump 2>&1 | batch --input -` piped
// stderr into the JSON stdin and broke batch's parse. That hazard
// only exists when the JSON array goes to STDOUT; once `--out`
// diverts it to a file, stdout carries just the path and stderr is
// free for human-facing warnings. Without this, orphan-note /
// dropped-drawing warnings were invisible on the most common
// `dump file -o out.json` invocation (text mode). `--out -` (stdout)
// normalizes to null below, so it correctly stays suppressed.
var outIsFile = !string.IsNullOrEmpty(outPath) && outPath != "-";
var warnToStderr = json || outIsFile;
// BUG-R4-01: route open through DocumentHandlerFactory so the
// FileFormatException / OpenXmlPackageException → CliException
// (code=corrupt_file) wrapping applies. Without this, direct
// `new WordHandler(...)` / `new PowerPointHandler(...)` leaks the
// raw OOXML SDK exception out of programmatic callers (tests,
// resident batch) — SafeRun catches it at the CLI surface but
// any in-process consumer sees the unwrapped form.
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false);
if (ext == ".docx")
{
var word = (WordHandler)handler;
var (dItems, dWarnings) = WordBatchEmitter.EmitWordWithWarnings(word, path);
items = dItems;
if (dWarnings.Count > 0)
{
// R10-bug1: mirror pptx wiring exactly so docx warnings
// land in the envelope's `warnings` array AND emit a
// stderr line for human consumption (resident's
// BuildWarnings picks the stderr line up too).
dumpWarnings = new List<CliWarning>(dWarnings.Count);
foreach (var w in dWarnings)
{
dumpWarnings.Add(new CliWarning
{
Message = $"skipped {w.Element} at {w.Path}: {w.Reason}",
Code = "unsupported_element"
});
// CONSISTENCY(dump-text-clean-output): emit to stderr in
// --json mode or when --out diverts the JSON to a file;
// see warnToStderr above for the stdout-pipe rationale.
if (warnToStderr)
Console.Error.WriteLine($"warning: skipped {w.Element} at {w.Path}: {w.Reason}");
}
}
}
else if (ext == ".pptx")
{
var ppt = (PowerPointHandler)handler;
var (pItems, pWarnings) = PptxBatchEmitter.EmitPptx(ppt, path);
items = pItems;
if (pWarnings.Count > 0)
{
dumpWarnings = new List<CliWarning>(pWarnings.Count);
foreach (var w in pWarnings)
{
dumpWarnings.Add(new CliWarning
{
Message = $"skipped {w.Element} on {w.SlidePath}: {w.Reason}",
Code = "unsupported_element"
});
// CONSISTENCY(dump-text-clean-output): emit to stderr in
// --json mode or when --out diverts the JSON to a file.
// See docx branch above for the stdout-pipe rationale.
if (warnToStderr)
Console.Error.WriteLine($"warning: skipped {w.Element} on {w.SlidePath}: {w.Reason}");
}
}
}
else // .xlsx
{
var xl = (ExcelHandler)handler;
var (xItems, xWarnings) = ExcelBatchEmitter.EmitExcel(xl, path);
items = xItems;
if (xWarnings.Count > 0)
{
dumpWarnings = new List<CliWarning>(xWarnings.Count);
foreach (var w in xWarnings)
{
dumpWarnings.Add(new CliWarning
{
Message = $"skipped {w.Element} at {w.Path}: {w.Reason}",
Code = "unsupported_element"
});
// CONSISTENCY(dump-text-clean-output): see docx branch.
if (warnToStderr)
Console.Error.WriteLine($"warning: skipped {w.Element} at {w.Path}: {w.Reason}");
}
}
}
// Compact JSON (single line) is the canonical batch wire form:
// `batch run` consumes it directly and AI tooling pipes it through
// jq/grep without caring about indentation. We previously
// constructed a JsonSerializerOptions{WriteIndented=true} that was
// never threaded into Serialize — kept the compact behavior, just
// dropped the dead options block.
var output = JsonSerializer.Serialize(items, BatchJsonContext.Default.ListBatchItem);
// BUG-R4-FUZZ-3: Unix convention — `--out -` means stdout, not a
// file literally named "-". Without this, running `dump --out -`
// silently created a `-` file in the cwd (and could pollute the
// project tree if invoked from inside it).
if (outPath == "-")
outPath = null;
if (outPath != null)
{
// The on-disk file is the canonical batch wire form (bare
// JSON array) so it can feed `batch --input <file>`
// unchanged — wrapping it in an envelope would break
// batch consumption.
// CONSISTENCY(trailing-newline): stdout always ends with a
// newline (Console.WriteLine); pair it on the file form too
// so tools like `wc -l`, `git diff`, POSIX text-file
// expectations and pipe-vs-file consumers agree on the
// payload shape.
File.WriteAllText(outPath, output + "\n");
if (json)
{
// BUG-R6-01: previously stdout returned
// {"success": true, "data": "/tmp/out.json"}
// which was indistinguishable in shape from the
// no-out form (data is array). Make the file mode's
// envelope unambiguous by surfacing structured
// metadata under `data` instead of a bare path
// string. Callers can detect "data has outputFile" to
// disambiguate.
var meta = new System.Text.Json.Nodes.JsonObject
{
["outputFile"] = outPath,
["itemCount"] = items.Count
};
Console.WriteLine(OutputFormatter.WrapEnvelope(meta.ToJsonString(), dumpWarnings));
}
else
Console.WriteLine(outPath);
}
else
{
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelope(output, dumpWarnings));
else
Console.WriteLine(output);
}
return 0;
}, json); });
return dumpCommand;
}
}
+412
View File
@@ -0,0 +1,412 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildGetCommand(Option<bool> jsonOption)
{
var getFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var pathArg = new Argument<string>("path") { Description = "DOM path (e.g. /body/p[1]) or 'selected' to read the current watch selection" };
pathArg.DefaultValueFactory = _ => "/";
var depthOpt = new Option<int>("--depth") { Description = "Depth of child nodes to include" };
depthOpt.DefaultValueFactory = _ => 1;
var saveOpt = new Option<string?>("--save") { Description = "Extract the backing binary payload (picture/ole/media) to this file path" };
var getCommand = new Command("get", "Get a document node by path");
getCommand.Add(getFileArg);
getCommand.Add(pathArg);
getCommand.Add(depthOpt);
getCommand.Add(saveOpt);
getCommand.Add(jsonOption);
getCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(getFileArg)!;
var path = MsysPathHint.Restore(result.GetValue(pathArg)!)!;
var depth = result.GetValue(depthOpt);
// CONSISTENCY(dos-hardening): cap user-supplied depth so a huge
// --depth on a deeply-nested doc can't drive the node-building
// recursion (and its O(n^2) InnerText/OuterXml-per-node cost) into
// a multi-minute hang or stack overflow. See DocumentLimits.
if (depth > DocumentLimits.MaxRecursionDepth)
depth = DocumentLimits.MaxRecursionDepth;
var savePath = result.GetValue(saveOpt);
// Special pseudo-path "selected" — query the running watch process
// for the currently-selected element paths and resolve them to nodes.
if (string.Equals(path, "selected", StringComparison.OrdinalIgnoreCase))
{
return GetSelectedAction(file.FullName, depth, json);
}
if (TryResident(file.FullName, req =>
{
req.Command = "get";
req.Json = json;
req.Args["path"] = path;
req.Args["depth"] = depth.ToString();
if (!string.IsNullOrEmpty(savePath)) req.Args["save"] = savePath;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName);
var node = handler.Get(path, depth);
// CONSISTENCY(get-not-found-exit): some handler Get paths surface
// "not found" via DocumentNode { Type = "error" } instead of
// throwing (e.g. /numbering/abstractNum[@id=999]). Other paths
// throw and exit 1 via SafeRun. Treat error-type nodes the same
// way so callers get a consistent non-zero exit on missing paths.
if (string.Equals(node.Type, "error", StringComparison.Ordinal))
{
var err = node.Text ?? $"Path not found: {path}";
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else
Console.Error.WriteLine($"Error: {err}");
return 1;
}
// --save <path>: extract the binary payload backing an OLE /
// picture / media node to disk. The handler exposes this via
// TryExtractBinary which looks up the node's relId and copies
// the part's stream. When the node has no backing binary, we
// surface a clear error instead of silently succeeding.
if (!string.IsNullOrEmpty(savePath))
{
if (!handler.TryExtractBinary(path, savePath, out var contentType, out var byteCount))
{
var err = $"Node at '{path}' has no binary payload to extract (only ole/picture/media/embedded nodes can be saved).";
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else
Console.Error.WriteLine($"Error: {err}");
return 1;
}
node.Format["savedTo"] = savePath;
node.Format["savedBytes"] = byteCount;
if (!string.IsNullOrEmpty(contentType))
node.Format["savedContentType"] = contentType!;
}
if (json)
// Unified envelope contract: single-path get returns the same
// {matches, results: [...]} shape as `get selected` and `query`,
// so agents and scripts can use one jq path everywhere. Text
// mode keeps the rich single-node rendering.
Console.WriteLine(OutputFormatter.WrapEnvelope(
OutputFormatter.FormatNodes(new List<DocumentNode> { node }, OutputFormat.Json)));
else
Console.WriteLine(OutputFormatter.FormatNode(node, OutputFormat.Text));
return 0;
}, json); });
return getCommand;
}
private static int GetSelectedAction(string filePath, int depth, bool json)
{
var paths = WatchNotifier.QuerySelection(filePath);
if (paths == null)
{
var msg = $"no watch running for {Path.GetFileName(filePath)}. Start one with: officecli watch \"{filePath}\"";
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
else
Console.Error.WriteLine($"Error: {msg}");
return 1;
}
// Resolve each path to a DocumentNode. Skip paths that no longer exist
// (e.g. element removed since selection was made) — silently drop them.
var nodes = new List<OfficeCli.Core.DocumentNode>();
if (paths.Length > 0)
{
using var handler = DocumentHandlerFactory.Open(filePath);
foreach (var p in paths)
{
try
{
var n = handler.Get(p, depth);
if (n != null) nodes.Add(n);
}
catch
{
// path no longer resolves — drop
}
}
}
// Flatten row/column nodes into their children so text output is
// grep-friendly (one cell per line instead of a single "/Sheet1/col[C]" line).
var flat = new List<OfficeCli.Core.DocumentNode>();
foreach (var n in nodes)
{
if (n.Children.Count > 0 && n.Type is "column" or "row")
flat.AddRange(n.Children);
else
flat.Add(n);
}
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelope(
OutputFormatter.FormatNodes(flat, OutputFormat.Json)));
}
else
{
Console.WriteLine(OutputFormatter.FormatNodes(flat, OutputFormat.Text));
}
return 0;
}
private static Command BuildQueryCommand(Option<bool> jsonOption)
{
var queryFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var selectorArg = new Argument<string>("selector") { Description = "CSS-like selector (e.g. paragraph[style=Normal] > run[font!=Arial])" };
var queryFindOpt = new Option<string?>("--find") { Description = "Filter results to elements containing this text (case-insensitive substring)" };
var queryCompactOpt = new Option<bool>("--compact") { Description = "One line per element in document order: path<TAB>[label]<TAB>\"text(truncated at 60, … mark)\"; empty text shows (empty); tables fold to [table RxC]. Final line is always 'total: N of M elements / K slides' (pptx) or 'total: N of M elements' (docx — never gains a container segment): N = element lines above (lineCount-1 == N proves you read everything), M = all top-level frames. Full-document listing: selector '*' (pptx) or 'paragraph, table' (docx) makes N == M. Labels are a closed set (pptx: title/placeholder/textbox/shape/picture/chart/connector/group/equation + 'table RxC'; docx: style name). This format is a stability contract: columns/labels may be added, never changed or reordered. pptx/docx only (xlsx: use 'view text --range'). Add columns with --fields." };
var queryFieldsOpt = new Option<string?>("--fields") { Description = "Comma-separated Format keys appended as extra k=v columns in --compact output (e.g. x,y,width)" };
var queryCommand = new Command("query", "Query document elements with CSS-like selectors");
queryCommand.Add(queryFileArg);
queryCommand.Add(selectorArg);
queryCommand.Add(jsonOption);
queryCommand.Add(queryFindOpt);
queryCommand.Add(queryCompactOpt);
queryCommand.Add(queryFieldsOpt);
queryCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(queryFileArg)!;
var selector = MsysPathHint.Restore(result.GetValue(selectorArg)!)!;
var textFilter = result.GetValue(queryFindOpt);
var compact = result.GetValue(queryCompactOpt);
var fields = result.GetValue(queryFieldsOpt);
if (compact && json)
throw new OfficeCli.Core.CliException("--compact is a plain-text line format; drop --json (or drop --compact for the JSON tree).") { Code = "invalid_value" };
if (TryResident(file.FullName, req =>
{
req.Command = "query";
req.Json = json;
req.Args["selector"] = selector;
if (textFilter != null) req.Args["find"] = textFilter;
if (compact) req.Args["compact"] = "true";
if (fields != null) req.Args["fields"] = fields;
}, json) is {} rc) return rc;
var format = json ? OutputFormat.Json : OutputFormat.Text;
using var handler = DocumentHandlerFactory.Open(file.FullName);
// CONSISTENCY(cell-selector-alias): the Excel cell selector accepts short
// aliases (bold -> font.bold, size -> font.size, ...). FilterSelector
// applies the same normalization, runs the boolean and/or engine, and
// routes a pure-AND (flat) selector through the exact legacy path.
// SelectorTargetsCells strips an optional sheet prefix (Sheet1!cell...,
// /Sheet1/cell...) before the element check — without it, sheet-scoped
// cell selectors skip alias normalization and drop all matches.
Func<string, string>? keyResolver =
handler is OfficeCli.Handlers.ExcelHandler
&& OfficeCli.Handlers.ExcelHandler.SelectorTargetsCells(selector)
? OfficeCli.Handlers.ExcelHandler.ResolveCellAttributeAlias : null;
var (results, warnings) = OfficeCli.Core.AttributeFilter.FilterSelector(selector, handler.Query, keyResolver);
if (!string.IsNullOrEmpty(textFilter))
results = results.Where(n => n.Text != null && OfficeCli.Core.AttributeFilter.MatchesTextFilter(n.Text, textFilter)).ToList();
if (compact)
{
foreach (var w in warnings) Console.Error.WriteLine(w.Message);
Console.WriteLine(FormatNodesCompact(handler, results, fields));
return 0;
}
if (json)
{
// CONSISTENCY(query-json-children): Query returns nodes with empty
// Children but populated ChildCount (handlers build query nodes at
// depth=0 to avoid expensive subtree walks). For --json output we
// hydrate children via Get(path, depth=1) so consumers see the same
// shape that `get --json` produces.
for (int i = 0; i < results.Count; i++)
{
var n = results[i];
if (n.ChildCount > 0 && n.Children.Count == 0 && !string.IsNullOrEmpty(n.Path))
{
try
{
var hydrated = handler.Get(n.Path, depth: 1);
if (hydrated?.Children != null && hydrated.Children.Count > 0)
n.Children.AddRange(hydrated.Children);
}
catch { /* path may not be Get-resolvable; leave as-is */ }
}
}
var cliWarnings = warnings.Select(w => new OfficeCli.Core.CliWarning
{
Message = w.Message,
Code = w.Code,
Kind = w.Kind,
Key = w.Key,
Value = w.Value,
Available = w.Available,
Suggestion = w.Suggestion,
}).ToList();
Console.WriteLine(OutputFormatter.WrapEnvelope(
OutputFormatter.FormatNodes(results, OutputFormat.Json),
cliWarnings.Count > 0 ? cliWarnings : null));
}
else
{
foreach (var w in warnings) Console.Error.WriteLine(w.Message);
var output = OutputFormatter.FormatNodes(results, OutputFormat.Text);
if (!string.IsNullOrEmpty(output))
Console.WriteLine(output);
if (results.Count == 0)
{
var ext = file.Extension.ToLowerInvariant().TrimStart('.');
Console.Error.WriteLine($"No matches. Run 'officecli {ext} query' for selector syntax.");
}
}
return 0;
}, json); });
return queryCommand;
}
/// <summary>
/// `query --compact` line format. STABILITY CONTRACT — this output is
/// consumed programmatically (parsed line-by-line, counted for
/// read-completeness accounting); treat every token below as API:
/// column order, the TAB separator, the `…` truncation mark, `(empty)`,
/// the `[label]` bracket form, and the total-line shape may only gain
/// NEW trailing columns, never change or reorder existing ones. Any
/// change lands in the CHANGELOG.
///
/// {path}\t[{label}]\t"{text ≤60 chars, \t/\n/"/\\ escaped}"
/// {path}\t[table {R}x{C}] (tables fold; no text col)
/// {path}\t[{label}]\t(empty) (no text)
/// ...--fields k1,k2 appends \tk1=v1\tk2=v2 columns (missing key → k=)
/// total: {N} of {M} elements / {K} slides (pptx)
/// total: {N} of {M} elements (docx)
///
/// N = exactly the number of lines above the total line (post-filter;
/// a folded table counts as 1) so `lineCount - 1 == N` proves the reader
/// saw the whole result. M = all top-level frames in the document
/// (pptx: shapes/pictures/tables/charts/connectors/groups across slides;
/// docx: body-level blocks). The total line is always emitted (N=0
/// included) and is always the last line, exactly once. The docx total
/// has NO container segment — that absence is itself frozen (appending
/// one later would be a total-line change, which the contract forbids).
///
/// Element lines are in document order: pptx sorts by slide index then
/// z-order (multi-type selectors like '*' would otherwise group by type),
/// docx follows document flow. Labels are a CLOSED SET per format —
/// pptx: title/placeholder/textbox/shape/picture/chart/connector/group/
/// equation + the folded 'table RxC'; docx: the paragraph's style name
/// (open set of values, fixed [style] position). New label values may be
/// added; existing ones never change meaning.
/// </summary>
internal static string FormatNodesCompact(IDocumentHandler handler, List<DocumentNode> results, string? fields)
{
if (handler is ExcelHandler)
throw new OfficeCli.Core.CliException(
"--compact is not supported for xlsx: 'view text' is already the compact per-row form ([/Sheet1/row[N]] A1=v ...). Use 'view text' or 'view text --range Sheet1!A1:C10'.")
{ Code = "invalid_value" };
var fieldList = string.IsNullOrWhiteSpace(fields)
? null
: fields.Split(',').Select(f => f.Trim()).Where(f => f.Length > 0).ToList();
// Document order (see contract above): multi-type selectors return
// results grouped per element type; re-sort pptx frames by slide index
// then z-order so the line sequence mirrors the deck. Stable sort keeps
// relative order where either key is missing. docx query results
// already follow document flow.
if (handler is PowerPointHandler)
{
results = results
.Select((n, i) => (n, i))
.OrderBy(t => System.Text.RegularExpressions.Regex.Match(t.n.Path, @"^/slide\[(\d+)\]") is { Success: true } m
? int.Parse(m.Groups[1].Value) : int.MaxValue)
.ThenBy(t => t.n.Format.TryGetValue("zorder", out var z) && int.TryParse(z?.ToString(), out var zi)
? zi : int.MaxValue)
.ThenBy(t => t.i)
.Select(t => t.n)
.ToList();
}
var sb = new System.Text.StringBuilder();
foreach (var n in results)
{
sb.Append(n.Path);
if (n.Type == "table" && n.Format.TryGetValue("rows", out var r) && n.Format.TryGetValue("cols", out var c))
{
sb.Append('\t').Append($"[table {r}x{c}]");
}
else
{
// pptx Type already carries the semantic label (title/placeholder/
// textbox/shape/...); docx paragraphs label by style name.
var label = !string.IsNullOrEmpty(n.Style) ? n.Style : n.Type;
sb.Append('\t').Append('[').Append(label).Append(']');
sb.Append('\t').Append(CompactText(n.Text));
}
if (fieldList != null)
foreach (var f in fieldList)
sb.Append('\t').Append(f).Append('=')
.Append(n.Format.TryGetValue(f, out var v) && v != null ? v.ToString() : "");
sb.Append('\n');
}
var (total, containerSuffix) = CountCompactDenominator(handler);
sb.Append($"total: {results.Count} of {total} elements{containerSuffix}");
return sb.ToString();
}
private static string CompactText(string? text)
{
if (string.IsNullOrEmpty(text)) return "(empty)";
var t = text.Replace("\\", "\\\\").Replace("\t", "\\t")
.Replace("\r", "").Replace("\n", "\\n").Replace("\"", "\\\"");
if (t.Length > 60) t = t[..60] + "…";
return "\"" + t + "\"";
}
/// <summary>
/// Denominator for the compact total line: every top-level frame in the
/// document, independent of the user's selector, so the agent can compare
/// "what I matched" against "what exists".
/// </summary>
private static (int Total, string ContainerSuffix) CountCompactDenominator(IDocumentHandler handler)
{
if (handler is PowerPointHandler)
{
int slides = handler.Query("slide").Count;
// Frame selectors overlap (title/textbox are shapes); dedupe by path.
var paths = new HashSet<string>();
foreach (var sel in new[] { "shape", "picture", "table", "chart", "connector", "group" })
{
try { foreach (var n in handler.Query(sel)) paths.Add(n.Path); }
catch { /* selector unsupported on this doc — skip */ }
}
return (paths.Count, $" / {slides} slides");
}
// docx: body-level content blocks (paragraphs + tables + sdt + ...).
// sectPr is layout metadata, not an addressable element — exclude it
// so the denominator matches what a selector can actually reach.
try
{
var body = handler.Get("/body", depth: 1);
return (body.Children.Count(c => c.Type != "section"), "");
}
catch
{
return (0, "");
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
namespace OfficeCli;
static partial class CommandBuilder
{
// ==================== goto ====================
//
// Push a one-shot scroll target to all SSE clients of a running watch.
// Does not open the file, does not mutate cached HTML, does not bump
// the version — pure runtime navigation. Mirrors mark/unmark in being
// a separate top-level command that talks to watch over the named
// pipe (CONSISTENCY(watch-runtime-cmd)).
//
// Word: path like /body/p[5] or /body/table[2] — resolves via
// WatchMessage.ExtractWordScrollTarget. PPT/Excel: not yet wired in
// (anchor coverage is the gap, not the command itself).
private static Command BuildGotoCommand(Option<bool> jsonOption, string name = "goto")
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path (.docx)" };
var pathArg = new Argument<string>("path") { Description = "Element path to scroll to (e.g. /body/p[5], /body/table[1], /body/table[1]/tr[2]/tc[3])" };
var cmd = new Command(name,
"Scroll the running watch viewer(s) to the given element. Path resolves to an HTML anchor; broadcast to all SSE clients of the file. Word: paragraph, table, table row, table cell.");
cmd.Add(fileArg);
cmd.Add(pathArg);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var path = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathArg)!)!;
var selector = WatchMessage.ExtractWordScrollTarget(path);
if (selector == null)
{
var err = $"Cannot resolve scroll target for path '{path}'. Supported: /body/p[N], /body/paragraph[N], /body/table[N], /body/table[N]/tr[R], /body/table[N]/tr[R]/tc[C].";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
}
if (!WatchServer.IsWatching(file.FullName))
{
var err = $"No watch process is running for {file.Name}.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
// BUG-BT-R33-3: validate the selector against the watch server's
// cached HTML snapshot before reporting success. Previously goto
// exited 0 even when the anchor didn't exist (e.g. /body/p[99] in
// a 4-paragraph doc).
var scroll = WatchNotifier.TryScroll(file.FullName, selector);
if (scroll.Kind == ScrollResult.K.NotFound)
{
var err = $"Cannot scroll to '{path}': {scroll.Error}.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
if (scroll.Kind == ScrollResult.K.NoWatch)
{
var err = $"No watch process is running for {file.Name}.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
var msg = $"Scrolled watcher(s) to {path} ({selector})";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else Console.WriteLine(msg);
return 0;
}, json); });
return cmd;
}
}
+415
View File
@@ -0,0 +1,415 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Help;
namespace OfficeCli;
static partial class CommandBuilder
{
// Recognized verbs that route help through the operation-scoped filter.
// Matches IDocumentHandler's public surface — keep in sync if new verbs
// are added to the handler API.
private static readonly string[] HelpVerbs =
{ "add", "set", "get", "query", "remove" };
// Commands that are NOT registered as System.CommandLine subcommands but
// are instead early-dispatched in Program.cs. They do not understand
// `--help` (install would actually run InstallBinary!), so the help
// dispatcher must print their usage itself rather than shell out.
// Keep these usage blurbs in sync with the Console.Error.WriteLine
// blocks in Program.cs (mcp: ~line 40, skills: ~line 87, install path:
// documented via Installer.Run).
/// <summary>
/// Print the verbose usage block for an early-dispatch command
/// (mcp/skills/install) to the given writer. Single source of truth shared
/// between `officecli help &lt;cmd&gt;`, the integration stubs' SetAction, and
/// Program.cs's invalid-args error path. Returns true if the command name
/// was recognized.
/// </summary>
internal static bool WriteEarlyDispatchUsage(string name, TextWriter writer)
{
// `skill` is the singular alias of `skills` (Program.cs accepts both as
// the early-dispatch token). Normalize here so `officecli skill --help`
// and `officecli help skill` resolve to the same usage block.
if (string.Equals(name, "skill", StringComparison.OrdinalIgnoreCase))
name = "skills";
if (!EarlyDispatchHelp.TryGetValue(name, out var lines)) return false;
foreach (var line in lines) writer.WriteLine(line);
return true;
}
private static readonly Dictionary<string, string[]> EarlyDispatchHelp =
new(StringComparer.OrdinalIgnoreCase)
{
["mcp"] = new[]
{
"Usage:",
" officecli mcp Start MCP stdio server (for AI agents)",
" officecli mcp <target> Register officecli with an MCP client",
" officecli mcp uninstall <target> Unregister officecli from an MCP client",
" officecli mcp list Show registration status across all clients",
"",
"Targets: lms (LM Studio), claude (Claude Code), cursor, vscode (Copilot)",
},
["skills"] = new[]
{
"Usage:",
" officecli skills install Install base SKILL.md to all detected agents",
" officecli skills install <skill-name> Install a specific skill to all detected agents",
" officecli skills install <skill-name> <agent> Install a specific skill to a single agent (either order works)",
" officecli skills <agent> Install base SKILL.md to a specific agent",
" officecli skills list List all available skills",
"",
"Skills: pptx, word, excel, word-form, morph-ppt, morph-ppt-3d, pitch-deck, academic-paper, data-dashboard, financial-model",
"Agents: claude, copilot, codex, cursor, windsurf, minimax, opencode, openclaw, nanobot, zeroclaw, hermes, all",
},
["load_skill"] = new[]
{
"Usage:",
" officecli load_skill List all skills with the triggers that say when to use each",
" officecli load_skill <name> Print the skill's SKILL.md + a manifest of its bundled reference files",
" officecli load_skill <name> --path <relpath> Print one bundled reference file (e.g. --path reference/decision-rules.md)",
"",
"Skills: pptx, word, excel, word-form, morph-ppt, morph-ppt-3d, pitch-deck, academic-paper, data-dashboard, financial-model",
"To install a skill (with binary assets) on disk, run: officecli skills install <name>",
},
["install"] = new[]
{
"Usage:",
" officecli install One-step setup: install binary + skills + MCP to all detected agents",
" officecli install <target> Install to a specific agent (claude, copilot, cursor, vscode, ...)",
"",
"Equivalent to: installing the binary, then `officecli skills install` and `officecli mcp <target>`.",
"Targets: claude, copilot, codex, cursor, windsurf, vscode, minimax, opencode, openclaw, nanobot, zeroclaw, hermes, all",
},
};
/// <summary>
/// `officecli help [format] [verb] [element] [--json]` — schema-driven help.
///
/// Argument forms accepted:
/// help → list formats
/// help &lt;format&gt; → list all elements
/// help &lt;format&gt; &lt;verb&gt; → list elements supporting that verb
/// help &lt;format&gt; &lt;element&gt; → full element detail
/// help &lt;format&gt; &lt;verb&gt; &lt;element&gt; → verb-filtered element detail
///
/// The middle arg is interpreted as verb iff it matches HelpVerbs.
/// Mirrors the actual CLI structure: `officecli &lt;verb&gt; &lt;file&gt; ...`, so
/// `officecli help docx add chart` reads exactly like the command you
/// are about to run.
/// </summary>
public static Command BuildHelpCommand(Option<bool> jsonOption, RootCommand? rootCommand = null)
{
var formatArg = new Argument<string?>("format")
{
Description = "Document format: docx/xlsx/pptx (aliases: word, excel, ppt, powerpoint). Omit to list formats.",
Arity = ArgumentArity.ZeroOrOne,
};
var secondArg = new Argument<string?>("verb-or-element")
{
Description = "Verb (add/set/get/query/remove) or element name. Omit to list all elements.",
Arity = ArgumentArity.ZeroOrOne,
};
var thirdArg = new Argument<string?>("element")
{
Description = "Element name when a verb was given (e.g. 'help docx add chart').",
Arity = ArgumentArity.ZeroOrOne,
};
// Scoped to `help` only — `help all`/`help <fmt> all` can emit either:
// --json one envelope-wrapped JSON document (matches other CLI
// commands; one parse for the whole corpus)
// --jsonl NDJSON (one self-contained JSON object per line, no
// envelope, streaming-friendly)
// Mutually exclusive on `help all`. Other help forms ignore --jsonl
// since they're either single documents (use --json) or human-readable
// listings with no JSON form.
var jsonlOption = new Option<bool>("--jsonl")
{
Description = "(help all only) Emit NDJSON: one JSON object per line, no envelope.",
};
var command = new Command("help", "Show schema-driven capability reference for officecli.");
command.Add(formatArg);
command.Add(secondArg);
command.Add(thirdArg);
command.Add(jsonOption);
command.Add(jsonlOption);
command.SetAction(result =>
{
var json = result.GetValue(jsonOption);
var jsonl = result.GetValue(jsonlOption);
var format = result.GetValue(formatArg);
var second = result.GetValue(secondArg);
var third = result.GetValue(thirdArg);
// Disambiguate middle arg: is it a verb or an element?
string? verb = null;
string? element = null;
if (second != null)
{
if (third != null)
{
// 3 args: format, verb, element — second is a verb only if it
// actually looks like one. If format is itself a HelpVerb (from
// the `<cmd> --help <format> <element>` rewrite) then second is
// a document format token, not a verb; leave verb=null so Case 1b
// handles it by showing SCL help for the command.
// CONSISTENCY(args-rewrite): mirrors the 2-arg guard below.
if (HelpVerbs.Contains(second, StringComparer.OrdinalIgnoreCase))
{
verb = second;
element = third;
}
else if (SchemaHelpLoader.IsKnownFormat(format!))
{
// format is a real schema format AND third is provided, but
// second isn't a verb — surface the error instead of
// silently falling through to Case 2 (which would list all
// elements, ignoring user input).
Console.Error.WriteLine(
$"error: unknown verb '{second}'. Valid: {string.Join(", ", HelpVerbs)}.");
return 1;
}
// else: format is a HelpVerb (CRUD-verb-as-format from the
// `<verb> --help <fmt> <element>` rewrite), second is the format
// token, third is the element — fall through with verb=null,
// element=null so Case 1b shows SCL command help.
}
else if (HelpVerbs.Contains(second, StringComparer.OrdinalIgnoreCase))
{
// 2 args where second is a verb: filter listing by verb.
verb = second;
}
else
{
// 2 args where second is NOT a verb: treat as element.
element = second;
}
}
return SafeRun(() => RunHelp(format, verb, element, json, jsonl, rootCommand), json);
});
return command;
}
private static int RunHelp(string? format, string? verb, string? element, bool json, bool jsonl, RootCommand? rootCommand)
{
// --json and --jsonl are mutually exclusive on `help all` / `help <fmt>
// all`: the first emits one envelope-wrapped JSON document, the second
// emits NDJSON. Combining them has no coherent meaning. Reject early
// with a clear message rather than silently picking one.
if (json && jsonl)
{
Console.Error.WriteLine("error: --json and --jsonl are mutually exclusive.");
return 1;
}
// Case 1: no args — print SCL's default help (Description, Usage,
// Options, full Commands list with arg signatures + descriptions),
// then append the schema-driven reference block. The SCL output is
// the single source of truth for the command surface; this command
// only adds what SCL doesn't know about (formats, schema verbs,
// aliases, drill-in usage).
// Use `== null` (not IsNullOrEmpty) so an explicit empty-string format
// (`help '' docx paragraph`) falls through to NormalizeFormat → proper
// "unknown format ''" error, instead of silently discarding the
// trailing tokens by routing into the no-args banner.
// CONSISTENCY(empty-arg) — mirrors the Case 2 element guard.
// Case 0: `help all` — flat, grep-friendly dump of every (format,
// element, property) row across the schema corpus. One self-contained
// line per record so `officecli help all | grep <term>` returns
// intelligible matches without context loss.
if (string.Equals(format, "all", StringComparison.OrdinalIgnoreCase))
{
if (verb != null || element != null)
{
Console.Error.WriteLine(
"error: 'help all' takes no further arguments. Pipe to grep to filter.");
return 1;
}
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelope(
SchemaHelpFlatRenderer.RenderAllJsonArray()));
return 0;
}
Console.Write(jsonl
? SchemaHelpFlatRenderer.RenderAllJsonl()
: SchemaHelpFlatRenderer.RenderAll());
return 0;
}
// Case 0b: `help <format> all` — same flat dump but filtered to one
// format. "all" isn't a CRUD verb so it lands in `element` after the
// upstream disambiguation. Saves the user a `| grep ^<format>`.
if (format != null
&& SchemaHelpLoader.IsKnownFormat(format)
&& verb == null
&& string.Equals(element, "all", StringComparison.OrdinalIgnoreCase))
{
var canonical = SchemaHelpLoader.NormalizeFormat(format);
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelope(
SchemaHelpFlatRenderer.RenderAllJsonArray(canonical)));
return 0;
}
Console.Write(jsonl
? SchemaHelpFlatRenderer.RenderAllJsonl(canonical)
: SchemaHelpFlatRenderer.RenderAll(canonical));
return 0;
}
if (format == null)
{
if (rootCommand != null)
{
// rootCommand.Parse(["--help"]) routes to SCL's HelpOption,
// which writes Description/Usage/Options/Commands directly to
// Console. Note Program.cs's `--help` → `help` rewrite only
// runs once at process startup on the original args, so this
// programmatic Parse goes straight to SCL and does not loop.
rootCommand.Parse(new[] { "--help" }).Invoke();
Console.WriteLine();
}
Console.WriteLine("Schema Reference (docx/xlsx/pptx):");
Console.WriteLine(" officecli help <format> List all elements");
Console.WriteLine(" officecli help <format> <verb> Elements supporting the verb");
Console.WriteLine(" officecli help <format> <element> Full element detail");
Console.WriteLine(" officecli help <format> <verb> <element> Verb-filtered element detail");
Console.WriteLine(" officecli help <format> <element> --json Raw schema JSON");
Console.WriteLine(" officecli help all Flat dump of every (format,element,property) — pipe to grep");
Console.WriteLine(" officecli help all --json Same dump as one envelope-wrapped JSON document");
Console.WriteLine(" officecli help all --jsonl Same dump as NDJSON (one JSON object per line)");
Console.WriteLine();
Console.Write(" Formats: ");
Console.WriteLine(string.Join(", ", SchemaHelpLoader.ListFormats()));
Console.WriteLine(" Verbs: add, set, get, query, remove");
Console.WriteLine(" Aliases: word→docx, excel→xlsx, ppt/powerpoint→pptx");
Console.WriteLine();
Console.WriteLine("Tip: most shells expand [brackets] — quote paths: officecli get doc.docx \"/body/p[1]\"");
return 0;
}
// Case 1b: not a format — try command help.
// - Early-dispatch commands (mcp/skills/install) don't understand
// --help (install would actually run InstallBinary!), so print
// a hardcoded usage blurb.
// - Registered SCL subcommands get their --help forwarded.
//
// CONSISTENCY(args-rewrite): `officecli set --help chart` is rewritten to
// `officecli help set chart` by Program.cs. "set" is not a document format,
// so we fall into this branch. The trailing element token ("chart") has no
// meaning in SCL command-help context — ignore it and show SCL help for "set".
// Guard drops `element == null` for CRUD verbs so the rewrite case is handled.
if (!SchemaHelpLoader.IsKnownFormat(format)
&& verb == null
&& (element == null || HelpVerbs.Contains(format, StringComparer.OrdinalIgnoreCase)
|| EarlyDispatchHelp.ContainsKey(format)
|| string.Equals(format, "skill", StringComparison.OrdinalIgnoreCase)))
{
if (WriteEarlyDispatchUsage(format, Console.Out))
return 0;
if (rootCommand != null)
{
var match = rootCommand.Subcommands.FirstOrDefault(
c => string.Equals(c.Name, format, StringComparison.OrdinalIgnoreCase)
&& !c.Hidden
&& c.Name != "help");
if (match != null)
return rootCommand.Parse(new[] { match.Name, "--help" }).Invoke();
}
}
// Validate verb if supplied.
if (verb != null && !HelpVerbs.Contains(verb, StringComparer.OrdinalIgnoreCase))
{
Console.Error.WriteLine($"error: unknown verb '{verb}'. Valid: {string.Join(", ", HelpVerbs)}.");
return 1;
}
var canonicalFormat = SchemaHelpLoader.NormalizeFormat(format);
// Case 2: format (+ optional verb) only — list elements.
// Use `== null` (not IsNullOrEmpty) so that an explicit empty-string
// arg (`help docx ''`) falls through to Case 3 where LoadSchema raises
// a proper "unknown element ''" error. CONSISTENCY(empty-arg).
if (element == null)
{
var all = SchemaHelpLoader.ListElements(canonicalFormat);
var filtered = verb == null
? all
: all.Where(el => SchemaHelpLoader.ElementSupportsVerb(canonicalFormat, el, verb!)).ToList();
if (filtered.Count == 0 && verb != null)
{
Console.WriteLine($"No elements in {canonicalFormat} support '{verb}'.");
return 0;
}
var header = verb == null
? $"Elements for {canonicalFormat}:"
: $"Elements for {canonicalFormat} supporting '{verb}':";
Console.WriteLine(header);
// Build parent → children map for tree rendering. Children whose
// declared parent isn't itself in the filtered set float back up
// to top-level so nothing disappears under a filter.
var filteredSet = new HashSet<string>(filtered, StringComparer.Ordinal);
var parentOf = filtered.ToDictionary(
el => el,
el => SchemaHelpLoader.GetParentForTree(canonicalFormat, el),
StringComparer.Ordinal);
var topLevel = new List<string>();
var byParent = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var el in filtered)
{
var pr = parentOf[el];
if (pr != null && filteredSet.Contains(pr))
{
if (!byParent.TryGetValue(pr, out var list))
byParent[pr] = list = new List<string>();
list.Add(el);
}
else
{
topLevel.Add(el);
}
}
void WriteNode(string el, int depth)
{
Console.WriteLine($"{new string(' ', 2 + depth * 2)}{el}");
if (byParent.TryGetValue(el, out var kids))
foreach (var kid in kids)
WriteNode(kid, depth + 1);
}
foreach (var el in topLevel)
WriteNode(el, 0);
Console.WriteLine();
var detailHint = verb == null
? $"Run 'officecli help {canonicalFormat} <element>' for detail."
: $"Run 'officecli help {canonicalFormat} {verb} <element>' for verb-filtered detail.";
Console.WriteLine(detailHint);
return 0;
}
// Case 3: format + (optional verb) + element — render schema.
using var doc = SchemaHelpLoader.LoadSchema(format, element);
Console.WriteLine(json
? SchemaHelpRenderer.RenderJson(doc)
: SchemaHelpRenderer.RenderHuman(doc, verb));
return 0;
}
}
+336
View File
@@ -0,0 +1,336 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using System.Text;
using OfficeCli.Core;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildImportCommand(Option<bool> jsonOption)
{
var importFileArg = new Argument<FileInfo>("file") { Description = "Target Excel file (.xlsx)" };
var importParentPathArg = new Argument<string>("parent-path") { Description = "Sheet path (e.g. /Sheet1)" };
var importSourceArg = new Argument<FileInfo?>("source-file") { Description = "Source CSV/TSV file to import (positional, alternative to --file)" };
importSourceArg.DefaultValueFactory = _ => null!;
var importSourceOpt = new Option<FileInfo?>("--file") { Description = "Source CSV/TSV file to import" };
var importStdinOpt = new Option<bool>("--stdin") { Description = "Read CSV/TSV data from stdin" };
var importFormatOpt = new Option<string?>("--format") { Description = "Data format: csv or tsv (default: inferred from file extension, or csv)" };
var importHeaderOpt = new Option<bool>("--header") { Description = "First row is header: set AutoFilter and freeze pane" };
var importStartCellOpt = new Option<string>("--start-cell") { Description = "Starting cell (default: A1)" };
importStartCellOpt.DefaultValueFactory = _ => "A1";
var importCommand = new Command("import", "Import CSV/TSV data into an Excel sheet");
importCommand.Add(importFileArg);
importCommand.Add(importParentPathArg);
importCommand.Add(importSourceArg);
importCommand.Add(importSourceOpt);
importCommand.Add(importStdinOpt);
importCommand.Add(importFormatOpt);
importCommand.Add(importHeaderOpt);
importCommand.Add(importStartCellOpt);
importCommand.Add(jsonOption);
importCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(importFileArg)!;
var parentPath = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(importParentPathArg)!)!;
var source = result.GetValue(importSourceOpt) ?? result.GetValue(importSourceArg);
var useStdin = result.GetValue(importStdinOpt);
var format = result.GetValue(importFormatOpt);
var header = result.GetValue(importHeaderOpt);
var startCell = result.GetValue(importStartCellOpt)!;
if (!file.Exists)
throw new CliException($"File not found: {file.FullName}")
{
Code = "file_not_found",
Suggestion = $"Create the file first: officecli create \"{file.FullName}\""
};
var ext = Path.GetExtension(file.FullName).ToLowerInvariant();
if (ext != ".xlsx")
throw new CliException("Import is only supported for .xlsx files in V1")
{
Code = "unsupported_type",
Suggestion = "Use a .xlsx file"
};
// Read CSV content
string csvContent;
if (useStdin)
{
csvContent = Console.In.ReadToEnd();
}
else if (source != null)
{
if (!source.Exists)
throw new CliException($"Source file not found: {source.FullName}")
{
Code = "file_not_found"
};
csvContent = File.ReadAllText(source.FullName, Encoding.UTF8);
}
else
{
throw new CliException("Either --file or --stdin must be specified")
{
Code = "missing_argument",
Suggestion = "Use --file <path> to specify a CSV/TSV file, or --stdin to read from standard input"
};
}
// Determine delimiter: --format flag > file extension > default csv
char delimiter = ',';
if (!string.IsNullOrEmpty(format))
{
delimiter = format.ToLowerInvariant() switch
{
"tsv" => '\t',
"csv" => ',',
_ => throw new CliException($"Unknown format: {format}. Use 'csv' or 'tsv'")
{
Code = "invalid_value",
ValidValues = ["csv", "tsv"]
}
};
}
else if (source != null)
{
var sourceExt = Path.GetExtension(source.FullName).ToLowerInvariant();
if (sourceExt == ".tsv" || sourceExt == ".tab")
delimiter = '\t';
}
// Release any running resident's file lock before direct-open (import bypasses resident)
ResidentClient.SendClose(file.FullName);
using var handler = new OfficeCli.Handlers.ExcelHandler(file.FullName, editable: true);
var msg = handler.Import(parentPath, csvContent, delimiter, header, startCell);
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else
Console.WriteLine(msg);
return 0;
}, json); });
return importCommand;
}
private static Command BuildCreateCommand(Option<bool> jsonOption)
{
var createFileArg = new Argument<string>("file") { Description = "Output file path (.docx, .xlsx, .pptx)" };
var createTypeOpt = new Option<string>("--type") { Description = "Document type (docx, xlsx, pptx) — optional, inferred from file extension" };
var createForceOpt = new Option<bool>("--force") { Description = "Overwrite an existing file." };
var createLocaleOpt = new Option<string>("--locale") { Description = "Locale tag (e.g. zh-CN, ja, ko, ar, he) — sets per-script default fonts in docDefaults and enables RTL layout for Arabic / Hebrew / Persian / Urdu and similar locales. Without this flag, the OS user culture (CFLocale on macOS, $LANG on Linux, user UI culture on Windows) is used as the default. Pass --locale en-US to force a deterministic LTR/Latin baseline regardless of the host machine. Currently only honored for .docx." };
var createMinimalOpt = new Option<bool>("--minimal") { Description = "(.docx only) Skip Word's Normal.dotm-style baseline (Calibri 11pt + Normal style + theme1.xml) and emit a raw OOXML-spec docx instead. Use for testing edge cases or producing maximally compact output. Without this flag, the doc carries Word-aligned defaults so it renders identically in Word, other producers, and the cli preview." };
var createCommand = new Command("create", "Create a blank Office document");
createCommand.Add(createFileArg);
createCommand.Add(createTypeOpt);
createCommand.Add(createForceOpt);
createCommand.Add(createLocaleOpt);
createCommand.Add(createMinimalOpt);
createCommand.Add(jsonOption);
createCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(createFileArg)!;
var type = result.GetValue(createTypeOpt);
var force = result.GetValue(createForceOpt);
var explicitLocale = result.GetValue(createLocaleOpt);
var minimal = result.GetValue(createMinimalOpt);
// Fall back to OS user culture when --locale is not explicitly
// given. Empty / C / POSIX cultures yield null (no locale baked)
// so CI environments produce neutral output.
var locale = OfficeCli.Core.LocaleFontRegistry.ResolveEffectiveLocale(explicitLocale);
bool localeInferred = !string.IsNullOrWhiteSpace(locale)
&& string.IsNullOrWhiteSpace(explicitLocale);
// If file has no extension but --type is provided, append it
if (!string.IsNullOrEmpty(type) && string.IsNullOrEmpty(Path.GetExtension(file)))
{
var ext = type.StartsWith('.') ? type : "." + type;
file += ext;
}
// Check if the file is held by a resident process
var fullPath = Path.GetFullPath(file);
if (ResidentClient.TryConnect(fullPath, out _))
{
// Stale-resident recovery: if the on-disk file is gone (typical
// example-script pattern: os.remove(FILE) then `create`), the
// resident is pinning a path that no longer exists. Auto-close
// it and proceed — refusing here would force every example
// script to wrap `create` in a defensive `close`.
if (!File.Exists(fullPath))
{
ResidentClient.SendClose(fullPath);
}
else
{
throw new CliException($"{Path.GetFileName(file)} is currently opened by a resident process. Please run 'officecli close \"{file}\"' first.")
{
Code = "file_locked",
Suggestion = $"Run: officecli close \"{file}\""
};
}
}
// Refuse to silently overwrite an existing file unless --force is set.
// OpenXML SDK's Create truncates the target otherwise, which can destroy
// user data when an AI agent retries or mis-types the path.
if (File.Exists(fullPath) && !force)
{
throw new CliException($"File already exists: {file}. Use --force to overwrite.")
{
Code = "file_exists",
Suggestion = "Add --force flag or remove the file first."
};
}
if (File.Exists(fullPath) && force)
{
Console.Error.WriteLine($"Overwriting existing file: {file}");
}
OfficeCli.BlankDocCreator.Create(file, locale, minimal);
var fullCreatedPath = Path.GetFullPath(file);
// If a --force overwrite replaced a file that currently has a live
// watch session, push a full SSE refresh so the preview reflects the
// new (blank) document instead of the stale pre-overwrite content
// (issue #169). create replaces the whole file, so a full re-render
// is the only correct shape — mirrors swap / refresh. Only reachable
// when no resident pins the file (otherwise create fails file_locked
// above); the watch server itself never opens the file. Best-effort:
// a preview-refresh failure must never fail the create itself.
if (WatchServer.IsWatching(fullCreatedPath))
{
try
{
using var watchHandler = OfficeCli.Handlers.DocumentHandlerFactory.Open(fullCreatedPath, editable: false);
NotifyWatch(watchHandler, fullCreatedPath, null);
}
catch { /* preview refresh is best-effort; the file is already written */ }
}
// Best-effort: auto-start a short-lived resident process so
// follow-up commands on this freshly-created file hit the
// in-memory handler instead of re-opening from disk each time.
// Uses a 60s idle timeout (much shorter than `open`'s default
// 12min) so a stray `create` with no follow-up exits quickly.
// Failure here does NOT fail the command — the file is already
// on disk and all other commands still work via direct open.
var noAuto = Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_RESIDENT");
string? residentErr = null;
var residentStarted = noAuto == "1" || string.Equals(noAuto, "true", StringComparison.OrdinalIgnoreCase)
? false
: TryStartResidentProcess(fullCreatedPath, idleSeconds: 60, out residentErr);
var residentSuffix = residentStarted
? " (kept open in background for faster subsequent commands)"
: "";
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelopeText($"Created: {fullCreatedPath}{residentSuffix}"));
}
else
{
Console.WriteLine($"Created: {file}{residentSuffix}");
// Surface the inferred locale on stderr so the user can see
// when the OS culture shaped the doc (RTL layout, CJK fonts,
// etc.). Stays out of stdout / JSON envelope so scripts that
// pipe `create` output aren't disturbed.
if (localeInferred && Path.GetExtension(file).Equals(".docx", StringComparison.OrdinalIgnoreCase))
{
var rtlNote = OfficeCli.Core.LocaleFontRegistry.IsRightToLeft(locale) ? " (RTL layout enabled)" : "";
Console.Error.WriteLine($"Note: locale '{locale}' inferred from OS user culture{rtlNote}. Pass --locale to override.");
}
if (!residentStarted && !string.IsNullOrEmpty(residentErr))
{
Console.Error.WriteLine($"Note: resident auto-start failed ({residentErr}); falling back to direct file access.");
}
if (Path.GetExtension(file).Equals(".pptx", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine($" totalSlides: 0");
// Pair the unit so both dimensions agree (matches Get /
// readback after R40 — paired emit avoids mixing pt+cm).
var (cWStr, cHStr) = Core.EmuConverter.FormatEmuPaired(12192000, 6858000);
Console.WriteLine($" slideWidth: {cWStr}");
Console.WriteLine($" slideHeight: {cHStr}");
}
}
return 0;
}, json); });
return createCommand;
}
private static Command BuildMergeCommand(Option<bool> jsonOption)
{
var mergeTemplateArg = new Argument<string>("template") { Description = "Template file path (.docx, .xlsx, .pptx) with {{key}} placeholders" };
var mergeOutputArg = new Argument<string>("output") { Description = "Output file path" };
var mergeDataOpt = new Option<string>("--data") { Description = "JSON data or path to .json file", Required = true };
var mergeForceOpt = new Option<bool>("--force") { Description = "Overwrite an existing output file." };
var mergeCommand = new Command("merge", "Merge template with JSON data, replacing {{key}} placeholders");
mergeCommand.Add(mergeTemplateArg);
mergeCommand.Add(mergeOutputArg);
mergeCommand.Add(mergeDataOpt);
mergeCommand.Add(mergeForceOpt);
mergeCommand.Add(jsonOption);
mergeCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var template = result.GetValue(mergeTemplateArg)!;
var output = result.GetValue(mergeOutputArg)!;
var dataArg = result.GetValue(mergeDataOpt)!;
var force = result.GetValue(mergeForceOpt);
// If a resident holds the template with unsaved in-memory edits,
// ask it to flush to disk first — otherwise File.Copy reads the
// stale pre-edit bytes (resident saves itself; we never open the
// file here). Mirrors import's pre-direct-open resident handshake.
ResidentClient.SendSave(Path.GetFullPath(template));
var data = Core.TemplateMerger.ParseMergeData(dataArg);
var mergeResult = Core.TemplateMerger.Merge(template, output, data, force);
if (json)
{
var mergeData = new System.Text.Json.Nodes.JsonObject
{
["output"] = Path.GetFullPath(output),
["replacedKeys"] = mergeResult.UsedKeys.Count,
["unresolvedPlaceholders"] = new System.Text.Json.Nodes.JsonArray(
mergeResult.UnresolvedPlaceholders.Select(p => (System.Text.Json.Nodes.JsonNode)p).ToArray())
};
var unresolvedCount = mergeResult.UnresolvedPlaceholders.Count;
var message = unresolvedCount > 0
? $"Merged {mergeResult.UsedKeys.Count} key(s), {unresolvedCount} unresolved placeholder(s)"
: $"Merged {mergeResult.UsedKeys.Count} key(s)";
var jsonObj = new System.Text.Json.Nodes.JsonObject
{
["success"] = true,
["data"] = mergeData,
["message"] = message,
};
Console.WriteLine(jsonObj.ToJsonString(new System.Text.Json.JsonSerializerOptions { WriteIndented = false }));
}
else
{
Console.WriteLine($"Merged: {output}");
Console.WriteLine($" Replaced keys: {mergeResult.UsedKeys.Count}");
if (mergeResult.UnresolvedPlaceholders.Count > 0)
{
Console.Error.WriteLine($" Warning: {mergeResult.UnresolvedPlaceholders.Count} unresolved placeholder(s):");
foreach (var p in mergeResult.UnresolvedPlaceholders)
Console.Error.WriteLine($" - {{{{{p}}}}}");
}
}
return 0;
}, json); });
return mergeCommand;
}
}
@@ -0,0 +1,52 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
namespace OfficeCli;
static partial class CommandBuilder
{
// Stub Commands for the early-dispatch trio (mcp/skills/install).
// These never execute their SetAction during normal use — Program.cs
// intercepts those args before System.CommandLine sees them. The stubs
// exist purely so:
// 1. `officecli --help` lists them in its Commands section (no longer
// missing 3 commands relative to `officecli help`).
// 2. `officecli <cmd> --help` reaches SCL (Program.cs falls through
// on --help/-h) and prints the usage from EarlyDispatchHelp.
// Keep the usage strings in EarlyDispatchHelp (CommandBuilder.Help.cs)
// as the single source of truth; this file just re-emits them.
// Short blurbs shown both in `officecli --help`'s Commands list and at
// the top of `officecli <cmd> --help`. Detailed multi-line usage lives
// in EarlyDispatchHelp and is surfaced via `officecli help <cmd>` (the
// single source of truth for verbose usage). Each blurb ends with a
// hint pointing there, so `<cmd> --help` users discover it.
private static readonly Dictionary<string, string> StubBlurbs =
new(StringComparer.OrdinalIgnoreCase)
{
["mcp"] = "Start the MCP stdio server, or register/unregister officecli with an MCP client. Run 'officecli help mcp' for full usage.",
["skills"] = "Install agent skill definitions (Claude Code, Cursor, Copilot, ...). Run 'officecli help skills' for full usage.",
["install"] = "One-step setup: install binary + skills + MCP for detected agents. Run 'officecli help install' for full usage.",
};
internal static IEnumerable<Command> BuildIntegrationStubCommands()
{
foreach (var (name, blurb) in StubBlurbs)
{
var cmd = new Command(name, blurb);
// SetAction is defense-in-depth: with the args-rewrite + Program.cs
// early-dispatch this code path is unreachable in normal use, but
// it ensures programmatic callers (e.g. tests parsing rootCommand
// directly) still get a sensible verbose-usage printout instead
// of silent no-op. Routes to the same source of truth as
// `officecli help <cmd>` and the Program.cs error path.
cmd.SetAction(_ =>
{
WriteEarlyDispatchUsage(name, Console.Out);
return 0;
});
yield return cmd;
}
}
}
+372
View File
@@ -0,0 +1,372 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
namespace OfficeCli;
static partial class CommandBuilder
{
// ==================== mark ====================
// Canonical prop names accepted by `mark --prop`. Any other key triggers
// the unknown-prop warning. Lower-case for case-insensitive comparison
// (the prop dictionary itself is OrdinalIgnoreCase).
private static readonly HashSet<string> KnownMarkProps = new(StringComparer.OrdinalIgnoreCase)
{
"find", "color", "note", "tofix", "regex",
};
private static Command BuildMarkCommand(Option<bool> jsonOption, string name = "mark")
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path (.pptx, .xlsx, .docx)" };
var pathArg = new Argument<string>("path") { Description = "DOM path to the element to mark. The 'selected' pseudo-path still works but is discouraged: prefer `get selected` first, then `mark <path>` per path, so the target lives in the command line." };
var propsOpt = new Option<string[]>("--prop")
{
Description = "Mark property: find=..., color=..., note=..., tofix=..., regex=true",
AllowMultipleArgumentsPerToken = true,
};
var cmd = new Command(name,
"Attach an in-memory advisory mark to a document element via the watch process. Path must be in data-path format (e.g. /body/p[1]); 'selected' marks all selected elements.");
cmd.Add(fileArg);
cmd.Add(pathArg);
cmd.Add(propsOpt);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var path = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathArg)!)!;
var rawProps = result.GetValue(propsOpt) ?? Array.Empty<string>();
var props = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
string? deprecatedExpectValue = null;
foreach (var p in rawProps)
{
var eq = p.IndexOf('=');
if (eq <= 0) continue;
var key = p[..eq];
var val = p[(eq + 1)..];
// (a) Deprecated alias: `expect` was renamed to `tofix` in a052fb6.
// Route the value to `tofix` with a deprecation warning on stderr
// so old scripts/prompts continue to work instead of silently
// losing data. Explicit `--prop tofix=...` takes precedence.
if (string.Equals(key, "expect", StringComparison.OrdinalIgnoreCase))
{
deprecatedExpectValue = val;
continue;
}
// (c) Unknown prop — warn and ignore instead of dropping silently.
// This catches typos like --prop noet=... that previously produced
// a mark with missing fields and no diagnostic.
if (!KnownMarkProps.Contains(key))
{
Console.Error.WriteLine(
$"Warning: unknown property '{key}' for mark, ignored. " +
"Known: find, color, note, tofix, regex.");
continue;
}
props[key] = val;
}
if (deprecatedExpectValue != null)
{
if (props.ContainsKey("tofix"))
{
// Explicit `tofix` wins — the `expect` value is dropped.
// Warn the user the alias was shadowed so they don't wonder
// where their value went.
Console.Error.WriteLine(
"Warning: 'expect' has been renamed to 'tofix'. " +
"An explicit 'tofix' was also provided and takes precedence; " +
"the 'expect' value was ignored. Please update your scripts.");
}
else
{
props["tofix"] = deprecatedExpectValue;
Console.Error.WriteLine(
"Warning: 'expect' has been renamed to 'tofix'. " +
"The value has been applied to 'tofix'. Please update your scripts.");
}
}
// CONSISTENCY(find-regex): reuse WordHandler.Set.cs:60-61's regex→raw-string conversion
// so mark and set share the exact same find/regex vocabulary (literal | r"..." | regex=true flag).
// To change the find parsing protocol, grep "CONSISTENCY(find-regex)" and update every call site
// project-wide in one pass — never patch mark alone. See the project conventions Design Principles.
props.TryGetValue("find", out var findText);
findText ??= "";
if (props.TryGetValue("regex", out var regexFlag) && ParseHelpers.IsTruthySafe(regexFlag)
&& !findText.StartsWith("r\"") && !findText.StartsWith("r'"))
{
findText = $"r\"{findText}\"";
}
// Build the common prop set once — reused for every target path
// when the user passes the `selected` pseudo-path.
var findVal = string.IsNullOrEmpty(findText) ? null : findText;
var colorVal = props.TryGetValue("color", out var c) ? c : null;
var noteVal = props.TryGetValue("note", out var n) ? n : null;
var tofixVal = props.TryGetValue("tofix", out var e) ? e : null;
// Resolve the target path(s). For the 'selected' pseudo-path, pull the
// current selection from the running watch process and mark each path
// individually with the same prop set. Rationale: a block of selected
// elements is conceptually N independent marks (one per element); a
// single mark with N paths would need new wire-format plumbing and
// make find/stale semantics ambiguous.
//
// mark is advisory (no OOXML write), so the silent-retarget hazard
// that makes `set selected` discouraged-by-default is milder here.
// See CommandBuilder.Set.cs `selected` branch for the full rationale
// before extending this pseudo-path to additional mutation commands.
List<string> targetPaths;
if (string.Equals(path, "selected", StringComparison.Ordinal))
{
var selection = WatchNotifier.QuerySelection(file.FullName);
if (selection == null)
{
var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
if (selection.Length == 0)
{
var err = "No elements are currently selected. Click or drag-select in the watch browser first.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
targetPaths = new List<string>(selection);
}
else
{
targetPaths = new List<string> { path };
}
var createdIds = new List<string>();
var createdMarks = new List<WatchMark>();
foreach (var targetPath in targetPaths)
{
var req = new MarkRequest
{
Path = targetPath,
Find = findVal,
Color = colorVal,
Note = noteVal,
Tofix = tofixVal,
};
string? id;
try
{
id = WatchNotifier.AddMark(file.FullName, req);
}
catch (MarkRejectedException rex)
{
// BUG-BT-001: server rejected the request (invalid color, invalid
// path, etc.). Surface the actual reason instead of silently
// returning success with an empty id.
var msg = targetPaths.Count > 1 ? $"{targetPath}: {rex.Message}" : rex.Message;
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
else Console.Error.WriteLine(msg);
return 1;
}
if (id == null)
{
var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
createdIds.Add(id);
}
if (json)
{
// Fetch the resolved marks (server has populated matched_text +
// stale by now) and return them so AI consumers don't need a
// follow-up get-marks round-trip.
var full = WatchNotifier.QueryMarksFull(file.FullName);
if (full != null)
{
var idSet = new HashSet<string>(createdIds);
foreach (var m in full.Marks)
if (idSet.Contains(m.Id)) createdMarks.Add(m);
}
if (createdMarks.Count == targetPaths.Count)
{
if (targetPaths.Count == 1)
{
var payload = System.Text.Json.JsonSerializer.Serialize(
createdMarks[0], WatchMarkJsonOptions.WatchMarkInfo);
Console.WriteLine(payload);
}
else
{
// Array envelope mirrors MarksResponse shape (no version).
var payload = System.Text.Json.JsonSerializer.Serialize(
createdMarks.ToArray(), WatchMarkJsonOptions.WatchMarkArrayInfo);
Console.WriteLine(payload);
}
}
else
{
Console.WriteLine(OutputFormatter.WrapEnvelopeText(
$"Marked {targetPaths.Count} element(s) (ids={string.Join(",", createdIds)})"));
}
}
else
{
if (targetPaths.Count == 1)
Console.WriteLine($"Marked {targetPaths[0]} (id={createdIds[0]})");
else
Console.WriteLine($"Marked {targetPaths.Count} element(s) (ids={string.Join(",", createdIds)})");
}
return 0;
}, json); });
return cmd;
}
// ==================== unmark ====================
private static Command BuildUnmarkMarkCommand(Option<bool> jsonOption, string name = "unmark")
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var pathOpt = new Option<string?>("--path") { Description = "Element path to unmark" };
var allOpt = new Option<bool>("--all") { Description = "Remove all marks for this file" };
var cmd = new Command(name,
"Remove marks from the watch process. Specify --path <data-path> or --all.");
cmd.Add(fileArg);
cmd.Add(pathOpt);
cmd.Add(allOpt);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var pathVal = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(pathOpt));
var allVal = result.GetValue(allOpt);
// Require explicit choice — never silently default
if (allVal && !string.IsNullOrEmpty(pathVal))
{
var err = "Specify either --path or --all, not both.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
}
if (!allVal && string.IsNullOrEmpty(pathVal))
{
var err = "Must specify either --path <p> or --all.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 2;
}
var req = new UnmarkRequest { Path = pathVal, All = allVal };
var removed = WatchNotifier.RemoveMarks(file.FullName, req);
if (removed == null)
{
var err = $"No watch process is running for {file.Name}.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
var msg = $"Removed {removed} mark(s)";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else Console.WriteLine(msg);
return 0;
}, json); });
return cmd;
}
// ==================== get-marks ====================
private static Command BuildGetMarksCommand(Option<bool> jsonOption, string name = "get-marks")
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var cmd = new Command(name,
"List all marks currently held by the watch process.");
cmd.Add(fileArg);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
var full = WatchNotifier.QueryMarksFull(file.FullName);
if (full == null)
{
var err = $"No watch process is running for {file.Name}.";
// BUG-BT-R4-01: even on error the --json output must keep the
// {version, marks, error} shape so the SKILL.md jq pipeline
// (`.marks[] | ...`) doesn't crash with "Cannot iterate over
// null" when an agent runs the apply pipeline against a dead
// watch. Empty marks array is the natural "nothing to do" form;
// the error field carries the human-readable reason. Exit 1
// still signals failure to script-level checks.
if (json)
{
// JSON-escape the error message manually to avoid the
// reflection-based Serialize<string> overload (IL2026 trim
// warning under AOT). The set of chars that actually need
// escaping in this context is small.
var escaped = err.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t");
var emptyEnvelope = $"{{\"version\":0,\"marks\":[],\"error\":\"{escaped}\"}}";
Console.WriteLine(emptyEnvelope);
}
else Console.Error.WriteLine(err);
return 1;
}
var marks = full.Marks;
if (json)
{
// Top-level object {version, marks} — no envelope wrapping, no
// double-encoded JSON-inside-JSON. AI consumers parse once.
var payload = System.Text.Json.JsonSerializer.Serialize(
full, WatchMarkJsonOptions.MarksResponseInfo);
Console.WriteLine(payload);
}
else
{
if (marks.Length == 0)
{
Console.WriteLine("(no marks)");
}
else
{
Console.WriteLine($"id path find matched color note");
Console.WriteLine($"-- ------------------------------------------------ -------------------- ------- ------- ----");
foreach (var m in marks)
{
var matchedStr = m.MatchedText.Length == 0
? (m.Stale ? "(stale)" : "-")
: (m.MatchedText.Length == 1
? Truncate(m.MatchedText[0], 6)
: $"[{string.Join(",", m.MatchedText.Take(2).Select(t => Truncate(t, 4)))}]({m.MatchedText.Length})");
Console.WriteLine($"{m.Id,-3} {Truncate(m.Path, 48),-48} {Truncate(m.Find ?? "-", 20),-20} {matchedStr,-7} {Truncate(m.Color ?? "-", 7),-7} {Truncate(m.Note ?? "-", 30)}");
}
}
}
return 0;
}, json); });
return cmd;
}
private static string Truncate(string s, int max)
=> s.Length <= max ? s : s.Substring(0, max - 1) + "…";
}
+456
View File
@@ -0,0 +1,456 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using System.Text;
using System.Text.Json;
using OfficeCli.Core;
using OfficeCli.Core.Plugins;
using OfficeCli.Help;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildPluginsCommand(Option<bool> jsonOption)
{
var pluginsCommand = new Command("plugins", "Manage and inspect installed plugins");
pluginsCommand.Add(BuildPluginsListCommand(jsonOption));
pluginsCommand.Add(BuildPluginsInfoCommand(jsonOption));
pluginsCommand.Add(BuildPluginsLintCommand(jsonOption));
return pluginsCommand;
}
private static Command BuildPluginsListCommand(Option<bool> jsonOption)
{
var cmd = new Command("list", "List plugins discoverable in the standard search paths");
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var plugins = PluginRegistry.EnumerateAll();
if (json)
{
using var stream = new MemoryStream();
using (var w = new Utf8JsonWriter(stream))
{
w.WriteStartArray();
foreach (var p in plugins)
{
w.WriteStartObject();
w.WriteString("name", p.Manifest.Name);
w.WriteString("version", p.Manifest.Version);
w.WriteNumber("protocol", p.Manifest.Protocol);
w.WritePropertyName("kinds");
JsonSerializer.Serialize(w, p.Manifest.Kinds, PluginJsonContext.Default.ListString);
w.WritePropertyName("extensions");
JsonSerializer.Serialize(w, p.Manifest.Extensions, PluginJsonContext.Default.ListString);
if (p.Manifest.Tier is { } tier) w.WriteString("tier", tier);
w.WriteString("path", p.ExecutablePath);
// Per-plugin manifest warnings — emit the same data
// that the text-mode table prints in its trailing
// Warnings: section, so script/AI consumers of --json
// can react to drift without parsing stderr.
var pluginWarnings = p.Manifest.Warnings();
if (pluginWarnings.Count > 0)
{
w.WritePropertyName("warnings");
JsonSerializer.Serialize(w, pluginWarnings, PluginJsonContext.Default.ListString);
}
w.WriteEndObject();
}
w.WriteEndArray();
}
Console.WriteLine(OutputFormatter.WrapEnvelope(Encoding.UTF8.GetString(stream.ToArray())));
return 0;
}
if (plugins.Count == 0)
{
Console.WriteLine("No plugins installed.");
Console.WriteLine("");
Console.WriteLine("Plugins extend officecli to support additional formats (.doc, .hwpx, .pdf export, ...).");
Console.WriteLine("See: plugins/plugin-protocol.md for installation paths.");
return 0;
}
// Plain-text table.
var rows = plugins
.Select(p => new
{
Name = p.Manifest.Name,
Version = p.Manifest.Version,
Kinds = string.Join(",", p.Manifest.Kinds),
Exts = string.Join(",", p.Manifest.Extensions),
Path = p.ExecutablePath,
})
.ToList();
int wName = Math.Max(4, rows.Max(r => r.Name.Length));
int wVer = Math.Max(7, rows.Max(r => r.Version.Length));
int wKinds = Math.Max(5, rows.Max(r => r.Kinds.Length));
int wExts = Math.Max(11, rows.Max(r => r.Exts.Length));
Console.WriteLine($"{"NAME".PadRight(wName)} {"VERSION".PadRight(wVer)} {"KINDS".PadRight(wKinds)} {"EXTENSIONS".PadRight(wExts)} PATH");
foreach (var r in rows)
Console.WriteLine($"{r.Name.PadRight(wName)} {r.Version.PadRight(wVer)} {r.Kinds.PadRight(wKinds)} {r.Exts.PadRight(wExts)} {r.Path}");
// Surface manifest-level warnings discovered during enumeration —
// missing idle_timeout_seconds, unknown kinds, invalid target,
// missing format-handler vocabulary. These do not fail the
// listing, but they help plugin authors notice drift before
// users hit it at invocation time.
var withWarnings = plugins
.Select(p => (p.Manifest.Name, Warnings: p.Manifest.Warnings()))
.Where(t => t.Warnings.Count > 0)
.ToList();
if (withWarnings.Count > 0)
{
Console.WriteLine();
Console.WriteLine("Warnings:");
foreach (var (name, ws) in withWarnings)
foreach (var w in ws)
Console.WriteLine($" [{name}] {w}");
}
return 0;
}, json); });
return cmd;
}
private static Command BuildPluginsInfoCommand(Option<bool> jsonOption)
{
var nameArg = new Argument<string>("name")
{
Description = "Plugin name or path to its executable",
};
var cmd = new Command("info", "Show the full manifest for a single plugin");
cmd.Add(nameArg);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var target = result.GetValue(nameArg) ?? "";
var resolved = ResolveByNameOrPath(target);
if (resolved is null)
throw new CliException($"Plugin not found: '{target}'")
{
Code = "plugin_not_found",
Suggestion = "Run `officecli plugins list` to see installed plugins, or provide the absolute path to the plugin executable.",
};
// Re-read the manifest raw rather than re-serializing from our typed
// class: this preserves any extra fields the plugin emits beyond
// what PluginManifest knows about, so `plugins info` is faithful to
// the plugin's actual --info output.
using var p = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = resolved.ExecutablePath,
Arguments = "--info",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
}
};
p.Start();
// Async-drain BOTH streams before waiting — the synchronous
// stdout-only read deadlocked when a plugin emitted verbose
// diagnostics on stderr (same pitfall PluginRegistry.TryReadManifest
// documents), and the Kill fallback sat unreachable behind it.
var manifestTask = p.StandardOutput.ReadToEndAsync();
var drainErrTask = p.StandardError.ReadToEndAsync();
if (!p.WaitForExit(5000)) { try { p.Kill(true); } catch { } }
var rawManifest = manifestTask.Result;
_ = drainErrTask.Result;
if (json)
{
var envelope = new System.Text.Json.Nodes.JsonObject
{
["path"] = resolved.ExecutablePath,
["manifest"] = System.Text.Json.Nodes.JsonNode.Parse(rawManifest),
};
Console.WriteLine(OutputFormatter.WrapEnvelope(envelope.ToJsonString()));
return 0;
}
Console.WriteLine($"Path: {resolved.ExecutablePath}");
Console.WriteLine();
// Pretty-print the manifest JSON via Utf8JsonWriter (AOT-safe,
// unlike JsonSerializer.Serialize(JsonElement) which trips IL2026).
try
{
using var doc = JsonDocument.Parse(rawManifest);
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true }))
doc.RootElement.WriteTo(w);
Console.WriteLine(Encoding.UTF8.GetString(ms.ToArray()));
}
catch
{
Console.WriteLine(rawManifest);
}
return 0;
}, json); });
return cmd;
}
private static Command BuildPluginsLintCommand(Option<bool> jsonOption)
{
var nameArg = new Argument<string>("name")
{
Description = "Plugin name or path to its executable",
};
var fixtureOption = new Option<string?>("--fixture")
{
Description = "Path to a source file the dump-reader will read. " +
"Falls back to $OFFICECLI_LINT_FIXTURE if unset.",
};
var cmd = new Command("lint",
"Lint a dump-reader plugin against the main schema. " +
"Runs `plugin dump <fixture>`, parses the BatchItem stream, and verifies " +
"every --prop key on add commands is declared in the plugin's target-format " +
"schemas/help/<target>/*.json. Exits 1 if any unknown prop is found.");
cmd.Add(nameArg);
cmd.Add(fixtureOption);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var target = result.GetValue(nameArg) ?? "";
var fixturePath = result.GetValue(fixtureOption);
var resolved = ResolveByNameOrPath(target)
?? throw new CliException($"Plugin not found: '{target}'")
{
Code = "plugin_not_found",
Suggestion = "Run `officecli plugins list` to see installed plugins, or provide the absolute path to the plugin executable.",
};
// Only dump-readers are lintable today — the lint contract is
// "the plugin's emitted batch must use prop keys the schema
// recognises". Exporters and format-handlers have their own
// contracts (binary output, vocabulary block) that don't go
// through the schema validator.
if (!resolved.Manifest.Kinds.Contains("dump-reader"))
throw new CliException(
$"Plugin '{resolved.Manifest.Name}' is not a dump-reader; lint only applies to dump-reader plugins.")
{
Code = "unsupported_plugin_kind",
Suggestion = "Run `officecli plugins info <name>` to see what kinds the plugin advertises.",
};
// Resolve a fixture: explicit arg → env var. No bundled fallback —
// a fixture is a per-plugin concern; the plugin author (or CI)
// pins it via --fixture or $OFFICECLI_LINT_FIXTURE.
var srcExt = resolved.Manifest.Extensions.FirstOrDefault() ?? "";
fixturePath ??= Environment.GetEnvironmentVariable("OFFICECLI_LINT_FIXTURE");
if (string.IsNullOrEmpty(fixturePath) || !File.Exists(fixturePath))
throw new CliException(
$"No lint fixture available for plugin '{resolved.Manifest.Name}'" +
(string.IsNullOrEmpty(srcExt) ? "" : $" ({srcExt})") + ". " +
"Pass --fixture <path>, or set OFFICECLI_LINT_FIXTURE.")
{
Code = "lint_fixture_missing",
Suggestion = "Provide a small foreign-format file the plugin can dump.",
};
// The plugin's declared target format determines which schema
// tree to validate emitted props against (default: docx).
var schemaFormat = resolved.Manifest.ResolveTargetFormat();
// Run the plugin and stream JSONL.
var items = new List<BatchItem>();
var findings = new List<LintFinding>();
void OnLine(string raw)
{
// Mirrors DumpReaderInvoker: strip per-line UTF-8 BOM so a
// plugin that BOMs every JSONL line passes lint as well as
// it passes invocation.
var line = raw.TrimStart('').Trim();
if (line.Length == 0) return;
if (line[0] == '[')
throw new CliException(
$"Plugin '{resolved.Manifest.Name}' emitted a JSON array; protocol v1 requires JSONL (one BatchItem per line).")
{ Code = "corrupt_batch" };
BatchItem? item;
try { item = JsonSerializer.Deserialize(line, BatchJsonContext.Default.BatchItem); }
catch (JsonException ex)
{
throw new CliException(
$"Plugin '{resolved.Manifest.Name}' emitted invalid JSON at line #{items.Count}: {ex.Message}")
{ Code = "plugin_contract_violation" };
}
if (item is null) return;
items.Add(item);
}
var idle = resolved.Manifest.ResolveIdleTimeout("dump");
var runResult = PluginProcess.Run(new PluginProcess.RunOptions
{
ExecutablePath = resolved.ExecutablePath,
Arguments = new[] { "dump", fixturePath },
IdleTimeoutSeconds = idle,
OnStdoutLine = OnLine,
});
if (PluginProcess.LineCallbackError is CliException ce) throw ce;
if (PluginProcess.LineCallbackError is not null) throw PluginProcess.LineCallbackError;
if (runResult.IdleTimedOut)
throw new CliException(
$"Plugin '{resolved.Manifest.Name}' produced no output for {idle}s — likely hung.")
{ Code = "plugin_idle_timeout" };
if (runResult.ExitCode != 0)
throw new CliException(
$"Plugin '{resolved.Manifest.Name}' dump failed (exit {runResult.ExitCode}) on fixture '{fixturePath}': {TruncateForLint(runResult.Stderr, 500)}")
{ Code = "plugin_failed" };
// Validate both add and set props against the target-format
// schema. BatchItem.Type is used verbatim as the schema element
// name for add; set commands look up the element via the path's
// leaf type when available, falling back to lenient validation
// when the schema doesn't recognize the inferred element.
for (int i = 0; i < items.Count; i++)
{
var it = items[i];
if (it is null) continue;
var verb = (it.Command ?? "").ToLowerInvariant();
if (verb != "add" && verb != "set") continue;
if (it.Props == null || it.Props.Count == 0) continue;
// For add: explicit Type. For set: best-effort infer from the
// last segment of the path (e.g. "/p[1]/r[2]" → "r"). This
// matches main's own ValidateProperties contract.
string typeName = verb == "add"
? (it.Type ?? "")
: InferTypeFromPath(it.Path ?? "");
if (string.IsNullOrEmpty(typeName)) continue;
bool schemaExists;
try { using var _ = SchemaHelpLoader.LoadSchema(schemaFormat, typeName); schemaExists = true; }
catch { schemaExists = false; }
if (!schemaExists)
{
if (verb == "add")
findings.Add(new LintFinding(i, typeName, typeName, "<unknown_type>"));
// For set: silently skip unknown leaf types — the path
// might reference an aliased element the loader doesn't
// expose by name. Don't false-positive plugin authors.
continue;
}
var unknown = SchemaHelpLoader.ValidateProperties(
schemaFormat, typeName, verb, it.Props);
foreach (var key in unknown)
findings.Add(new LintFinding(i, typeName, typeName, key));
}
if (json)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms))
{
w.WriteStartObject();
w.WriteString("plugin", resolved.Manifest.Name);
w.WriteString("path", resolved.ExecutablePath);
w.WriteString("fixture", fixturePath);
w.WriteNumber("batch_items", items.Count);
w.WriteNumber("unknown_prop_count", findings.Count);
w.WritePropertyName("unknown_props");
w.WriteStartArray();
foreach (var f in findings)
{
w.WriteStartObject();
w.WriteNumber("index", f.Index);
w.WriteString("type", f.Type);
w.WriteString("element", f.Element);
w.WriteString("prop", f.Prop);
w.WriteEndObject();
}
w.WriteEndArray();
w.WriteEndObject();
}
Console.WriteLine(OutputFormatter.WrapEnvelope(Encoding.UTF8.GetString(ms.ToArray())));
}
else
{
Console.WriteLine($"Plugin: {resolved.Manifest.Name} ({resolved.ExecutablePath})");
Console.WriteLine($"Fixture: {fixturePath}");
Console.WriteLine($"Batch items: {items.Count}");
if (findings.Count == 0)
{
Console.WriteLine($"Result: OK — every emitted prop is declared in the {schemaFormat} schema.");
}
else
{
Console.WriteLine($"Result: {findings.Count} unknown prop(s):");
foreach (var f in findings)
Console.WriteLine($" [#{f.Index}] type={f.Type} element={f.Element} prop=\"{f.Prop}\" (not declared in schemas/help/{schemaFormat}/{f.Element}.json)");
}
// Surface manifest-level soft warnings (§4 recommended fields,
// unknown kinds, suspect target) so lint covers the same
// ground as `plugins list` without users having to run both.
var manifestWarnings = resolved.Manifest.Warnings();
if (manifestWarnings.Count > 0)
{
Console.WriteLine();
Console.WriteLine($"Manifest warnings:");
foreach (var w in manifestWarnings)
Console.WriteLine($" - {w}");
}
}
return findings.Count == 0 ? 0 : 1;
}, json); });
return cmd;
}
private sealed record LintFinding(int Index, string Type, string Element, string Prop);
private static string TruncateForLint(string s, int max) =>
s.Length <= max ? s : s.Substring(0, max) + "...";
/// <summary>
/// Best-effort extraction of the leaf element type from a path like
/// <c>"/body/p[1]/r[2]"</c> → <c>"r"</c>. Used by lint to look up the
/// schema for set commands, which (unlike add) do not carry an explicit
/// Type. Returns empty string for unrecognizable paths.
/// </summary>
private static string InferTypeFromPath(string path)
{
if (string.IsNullOrEmpty(path)) return "";
var slash = path.LastIndexOf('/');
var leaf = slash < 0 ? path : path.Substring(slash + 1);
var bracket = leaf.IndexOf('[');
if (bracket > 0) leaf = leaf.Substring(0, bracket);
return leaf;
}
private static ResolvedPlugin? ResolveByNameOrPath(string target)
{
// Path mode: absolute or relative path that exists.
if (target.Contains(Path.DirectorySeparatorChar) || target.Contains(Path.AltDirectorySeparatorChar) || File.Exists(target))
{
var full = Path.GetFullPath(target);
if (File.Exists(full) && PluginRegistry.TryReadManifest(full, out var m))
return new ResolvedPlugin(full, m);
return null;
}
// Name mode: search the full enumeration for a manifest whose name matches.
var all = PluginRegistry.EnumerateAll();
return all.FirstOrDefault(p =>
string.Equals(p.Manifest.Name, target, StringComparison.OrdinalIgnoreCase));
}
}
+152
View File
@@ -0,0 +1,152 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildRawCommand(Option<bool> jsonOption)
{
var rawFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var rawPathArg = new Argument<string>("part") { Description = "Part path (e.g. /document, /styles, /header[1])" };
rawPathArg.DefaultValueFactory = _ => "/document";
var rawStartOpt = new Option<int?>("--start") { Description = "Start row number (Excel sheets only)" };
var rawEndOpt = new Option<int?>("--end") { Description = "End row number (Excel sheets only)" };
var rawColsOpt = new Option<string?>("--cols") { Description = "Column filter, comma-separated (Excel only, e.g. A,B,C)" };
var rawCommand = new Command("raw", "View raw XML of a document part");
rawCommand.Add(rawFileArg);
rawCommand.Add(rawPathArg);
rawCommand.Add(rawStartOpt);
rawCommand.Add(rawEndOpt);
rawCommand.Add(rawColsOpt);
rawCommand.Add(jsonOption);
rawCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(rawFileArg)!;
var partPath = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(rawPathArg)!)!;
var startRow = result.GetValue(rawStartOpt);
var endRow = result.GetValue(rawEndOpt);
var rawColsStr = result.GetValue(rawColsOpt);
if (TryResident(file.FullName, req =>
{
req.Command = "raw";
req.Args["part"] = partPath;
if (startRow.HasValue) req.Args["start"] = startRow.Value.ToString();
if (endRow.HasValue) req.Args["end"] = endRow.Value.ToString();
if (rawColsStr != null) req.Args["cols"] = rawColsStr;
}, json) is {} rc) return rc;
var rawCols = rawColsStr != null ? new HashSet<string>(rawColsStr.Split(',').Select(c => c.Trim().ToUpperInvariant())) : null;
using var handler = DocumentHandlerFactory.Open(file.FullName);
var xml = handler.Raw(partPath, startRow, endRow, rawCols);
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(xml));
else Console.WriteLine(xml);
return 0;
}, json); });
return rawCommand;
}
private static Command BuildRawSetCommand(Option<bool> jsonOption)
{
var rawSetFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var rawSetPartArg = new Argument<string>("part") { Description = "Part path (e.g. /document, /styles, /Sheet1, /slide[1])" };
var rawSetXpathOpt = new Option<string>("--xpath") { Description = "XPath to target element(s)", Required = true };
var rawSetActionOpt = new Option<string>("--action") { Description = "Action: append, prepend, insertbefore, insertafter, replace, remove, setattr", Required = true };
var rawSetXmlOpt = new Option<string?>("--xml") { Description = "XML fragment or attr=value for setattr" };
var rawSetCommand = new Command("raw-set", "Modify raw XML in a document part (universal fallback for any OpenXML operation)");
rawSetCommand.Add(rawSetFileArg);
rawSetCommand.Add(rawSetPartArg);
rawSetCommand.Add(rawSetXpathOpt);
rawSetCommand.Add(rawSetActionOpt);
rawSetCommand.Add(rawSetXmlOpt);
rawSetCommand.Add(jsonOption);
rawSetCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(rawSetFileArg)!;
var partPath = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(rawSetPartArg)!)!;
var xpath = result.GetValue(rawSetXpathOpt)!;
var action = result.GetValue(rawSetActionOpt)!;
var xml = result.GetValue(rawSetXmlOpt);
if (TryResident(file.FullName, req =>
{
req.Command = "raw-set";
req.Args["part"] = partPath;
req.Args["xpath"] = xpath;
req.Args["action"] = action;
if (xml != null) req.Args["xml"] = xml;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
var errorsBefore = handler.Validate().Select(e => e.Description).ToHashSet();
handler.RawSet(partPath, xpath, action, xml);
var warnings = ReportNewErrorsAsWarnings(handler, errorsBefore);
var message = $"raw-set applied: {action} at {xpath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message, warnings));
else
{
Console.WriteLine(message);
ReportNewErrors(handler, errorsBefore, warnings);
}
NotifyWatch(handler, file.FullName, null);
return warnings is { Count: > 0 } ? 1 : 0;
}, json); });
return rawSetCommand;
}
private static Command BuildAddPartCommand(Option<bool> jsonOption)
{
var addPartFileArg = new Argument<string>("file") { Description = "Document file path" };
var addPartParentArg = new Argument<string>("parent") { Description = "Parent part path (e.g. / for document root, /Sheet1 for Excel sheet, /slide[0] for PPT slide)" };
var addPartTypeOpt = new Option<string>("--type") { Description = "Part type to create. Word: chart, header, footer. PPT/Excel: chart", Required = true };
var addPartCommand = new Command("add-part", "Create a new document part and return its relationship ID for use with raw-set");
addPartCommand.Add(addPartFileArg);
addPartCommand.Add(addPartParentArg);
addPartCommand.Add(addPartTypeOpt);
addPartCommand.Add(jsonOption);
addPartCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(addPartFileArg)!;
var parent = OfficeCli.Core.MsysPathHint.Restore(result.GetValue(addPartParentArg)!)!;
var type = result.GetValue(addPartTypeOpt)!;
if (TryResident(file, req =>
{
req.Command = "add-part";
req.Args["parent"] = parent;
req.Args["type"] = type;
}, json) is {} rc) return rc;
using var handler = DocumentHandlerFactory.Open(file, editable: true);
var errorsBefore = handler.Validate().Select(e => e.Description).ToHashSet();
var (relId, partPath) = handler.AddPart(parent, type);
var warnings = ReportNewErrorsAsWarnings(handler, errorsBefore);
var message = $"Created {type} part: relId={relId} path={partPath}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(message, warnings));
else
{
Console.WriteLine(message);
ReportNewErrors(handler, errorsBefore, warnings);
}
NotifyWatch(handler, file, null);
return 0;
}, json); });
return addPartCommand;
}
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildRefreshCommand(Option<bool> jsonOption)
{
var fileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var cmd = new Command("refresh", "Recalculate derived field values (TOC page numbers, PAGE/NUMPAGES, cross-references). Word + Windows required for .docx.");
cmd.Add(fileArg);
cmd.Add(jsonOption);
cmd.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(fileArg)!;
if (TryResident(file.FullName, req =>
{
req.Command = "refresh";
req.Json = json;
}, json) is { } rc) return rc;
var ext = Path.GetExtension(file.FullName).ToLowerInvariant();
if (ext != ".docx" && ext != ".docm")
throw new CliException($"refresh currently only supports .docx files (got {ext}).")
{ Code = "unsupported_type" };
bool ok = false;
string backend = "";
if (OperatingSystem.IsWindows())
{
ok = WordPdfBackend.RefreshFields(file.FullName);
if (ok) backend = "word";
}
if (!ok)
{
ok = WordHtmlRefresh.RefreshViaHtml(file.FullName);
if (ok) backend = "html";
}
if (!ok)
throw new CliException("refresh failed (Word backend unavailable and HTML fallback failed — no headless browser found).")
{ Code = "refresh_failed" };
var msg = $"Refreshed: {file.FullName} (backend: {backend})";
if (backend == "html")
Console.Error.WriteLine("Note: HTML fallback used. TOC page numbers reflect officecli's HTML pagination, which may differ from Word's layout.");
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else Console.WriteLine(msg);
return 0;
}, json); });
return cmd;
}
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
namespace OfficeCli;
static partial class CommandBuilder
{
// ==================== save command ====================
//
// Flush the resident's in-memory document to disk WITHOUT ending the
// session. Only meaningful inside a running resident — agents that build
// a workbook incrementally and need mid-build snapshots (e.g. for a
// third-party Excel viewer that ingests the .xlsx package directly) use
// this to recover the parse-amortization benefit of resident mode while
// still publishing snapshots on demand.
//
// Non-resident mode is rejected on purpose: each non-resident command
// already opens-mutates-closes (close = save), so there's no pending
// in-memory state to flush. A no-op success would invite confused
// "save just in case" code; an error tells the user they're in the
// wrong mode.
private static Command BuildSaveCommand(Option<bool> jsonOption)
{
var saveFileArg = new Argument<FileInfo>("file") { Description = "Office document path" };
var saveCommand = new Command("save", "Flush in-memory changes to disk, keeping the resident running. Run before a non-officecli program reads the file (officecli's own reads always see edits; a direct disk reader sees the pre-edit file until a flush). A live resident also auto-flushes shortly after going idle (adaptive 2-10s; see OFFICECLI_RESIDENT_FLUSH: each|auto|<seconds>|off). No-op if no resident is active.");
saveCommand.Add(saveFileArg);
saveCommand.Add(jsonOption);
saveCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(saveFileArg)!;
var filePath = file.FullName;
// Probe without auto-starting. `save` is meaningless without a
// pre-existing in-memory session, so we deliberately skip the
// TryResident auto-start path that other verbs use.
if (!ResidentClient.TryConnect(filePath, out _))
{
// No resident session to flush. In the non-resident model the
// document on disk is already current (each mutation eager-saved),
// so save is a no-op SUCCESS rather than an error — keeping
// "edit, then save/close" a safe habit regardless of backend.
var msg = $"{file.Name} is already saved to disk.";
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeText(msg));
else
Console.WriteLine(msg);
return 0;
}
var request = new ResidentRequest { Command = "save", Json = json };
var response = ResidentClient.TrySend(
filePath, request,
maxRetries: ResidentBusyMaxRetries,
connectTimeoutMs: ResidentBusyConnectTimeoutMs);
if (response == null)
{
var msg = $"Resident for {file.Name} is running but the save command could not be delivered (main pipe busy or unresponsive).";
if (json)
Console.WriteLine(OutputFormatter.WrapEnvelopeError(msg));
else
Console.Error.WriteLine($"Error: {msg}");
return 3;
}
if (!string.IsNullOrEmpty(response.Stdout))
Console.WriteLine(response.Stdout);
if (!string.IsNullOrEmpty(response.Stderr))
Console.Error.WriteLine(response.Stderr);
return response.ExitCode;
}, json); });
return saveCommand;
}
}
+429
View File
@@ -0,0 +1,429 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildSetCommand(Option<bool> jsonOption)
{
var forceOption = new Option<bool>("--force") { Description = "Force write even if document is protected" };
var setFileArg = new Argument<FileInfo>("file") { Description = "Office document path (required even with open/close mode)" };
var setPathArg = new Argument<string>("path") { Description = "DOM path to the element. The 'selected' pseudo-path is deprecated for mutations: use `get selected` to capture path(s) first, then `set <path>` (or a `batch` file for multi-select) so the target lives in the command line, not in transient watch-server state." };
var propsOpt = new Option<string[]>("--prop") { Description = "Property to set (key=value)", AllowMultipleArgumentsPerToken = true };
// Selector: top-level alternative to --prop find=VALUE. r"..." prefix triggers regex (project-wide CONSISTENCY(find-regex)).
var findOpt = new Option<string?>("--find") { Description = "Find this text/pattern (literal substring; `r\"...\"` prefix enables regex). Equivalent to --prop find=VALUE." };
// Action paired with --find: replacement text. Top-level alternative to --prop replace=VALUE.
var replaceOpt = new Option<string?>("--replace") { Description = "Replacement text for --find matches. Equivalent to --prop replace=VALUE." };
var setCommand = new Command("set", "Modify a document node's properties") { TreatUnmatchedTokensAsErrors = false };
setCommand.Add(setFileArg);
setCommand.Add(setPathArg);
setCommand.Add(propsOpt);
setCommand.Add(findOpt);
setCommand.Add(replaceOpt);
setCommand.Add(jsonOption);
setCommand.Add(forceOption);
setCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(setFileArg)!;
var path = MsysPathHint.Restore(result.GetValue(setPathArg)!)!;
var props = result.GetValue(propsOpt);
var findFlag = result.GetValue(findOpt);
var replaceFlag = result.GetValue(replaceOpt);
var force = result.GetValue(forceOption);
// Selector-flag migration: --find / --replace are the canonical
// top-level forms; --prop find=VALUE / --prop replace=VALUE remain
// accepted but emit a deprecation hint. Merging the flags into the
// props array (as "find=<value>" / "replace=<value>") keeps every
// downstream consumer (handlers, resident server, batch) working
// with zero changes — the flags are pure syntactic sugar.
var hasPropFind = props?.Any(p => p.StartsWith("find=", StringComparison.OrdinalIgnoreCase)) == true;
var hasPropReplace = props?.Any(p => p.StartsWith("replace=", StringComparison.OrdinalIgnoreCase)) == true;
if (findFlag != null && hasPropFind)
{
var err = "Cannot combine --find and --prop find=. Use --find only.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine($"Error: {err}");
return 1;
}
if (replaceFlag != null && hasPropReplace)
{
var err = "Cannot combine --replace and --prop replace=. Use --replace only.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine($"Error: {err}");
return 1;
}
if (findFlag != null || replaceFlag != null)
{
var merged = props?.ToList() ?? new List<string>();
if (findFlag != null) merged.Add($"find={findFlag}");
if (replaceFlag != null) merged.Add($"replace={replaceFlag}");
props = merged.ToArray();
}
else if (hasPropFind || hasPropReplace)
{
var legacy = hasPropFind && hasPropReplace ? "find / replace"
: hasPropFind ? "find" : "replace";
var flag = hasPropFind && hasPropReplace ? "--find / --replace"
: hasPropFind ? "--find" : "--replace";
Console.Error.WriteLine($"Hint: prefer `{flag} VALUE` over `--prop {legacy}=VALUE` (selector/action keys are migrating out of --prop).");
}
// BUG-BT-R5-01: support the `selected` pseudo-path (mark and get
// already do). Expand to the first selected path and recursively
// re-invoke set for any additional paths after the main set
// completes. CONSISTENCY(selected-pseudo): grep for the same
// pseudo-path handling in CommandBuilder.Mark.cs / GetQuery.cs.
//
// Discouraged for mutations, single- or multi-select alike.
// `set selected` resolves the selection at *execution* time, so
// if the user clicks a different element between deciding-to-set
// and the command running, this branch silently retargets the
// new element — no error, just the wrong object mutated. The
// canonical pattern is two-step: `get selected` to freeze the
// path(s) as strings, then issue explicit `set <path>` per path
// (or a `batch` file for multi-select). Every mutation command
// then carries its target in the command line itself — auditable,
// diffable, replayable from shell history. Out-of-band watch-server
// state must not influence what mutation commands target. Do not
// extend this pseudo-path to more mutation commands.
List<string>? extraSelectedPaths = null;
if (string.Equals(path, "selected", StringComparison.Ordinal))
{
var selection = WatchNotifier.QuerySelection(file.FullName);
if (selection == null)
{
var err = $"No watch process is running for {file.Name}. Start one with: officecli watch {file.Name}";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
if (selection.Length == 0)
{
var err = "No elements are currently selected. Click or drag-select in the watch browser first.";
if (json) Console.WriteLine(OutputFormatter.WrapEnvelopeError(err));
else Console.Error.WriteLine(err);
return 1;
}
path = selection[0];
if (selection.Length > 1)
{
extraSelectedPaths = new List<string>(selection.Length - 1);
for (int i = 1; i < selection.Length; i++) extraSelectedPaths.Add(selection[i]);
}
}
// Check document protection for .docx files
// Skip protection check if the user is changing the protection mode itself
var isProtectionChange = props?.Any(p => p.StartsWith("protection=", StringComparison.OrdinalIgnoreCase)) == true;
if (!force && !isProtectionChange && file.Extension.Equals(".docx", StringComparison.OrdinalIgnoreCase))
{
var protectionError = CheckDocxProtection(file.FullName, path, json);
if (protectionError != 0) return protectionError;
}
// Detect bare key=value positional arguments (missing --prop)
var unmatchedKvWarnings = DetectUnmatchedKeyValues(result);
if (unmatchedKvWarnings.Count > 0)
{
if (json)
{
var kvWarnings = unmatchedKvWarnings.Select(kv => new OfficeCli.Core.CliWarning
{
Message = $"Bare property '{kv}' ignored. Use --prop {kv}",
Code = "missing_prop_flag",
Suggestion = $"--prop {kv}"
}).ToList();
Console.WriteLine(OutputFormatter.WrapEnvelopeError(
$"Properties specified without --prop flag. Use: officecli set <file> <path> --prop {string.Join(" --prop ", unmatchedKvWarnings)}",
kvWarnings));
}
else
{
foreach (var kv in unmatchedKvWarnings)
Console.Error.WriteLine($"WARNING: Bare property '{kv}' ignored. Did you mean: --prop {kv}");
Console.Error.WriteLine("Hint: Properties must be passed with --prop flag, e.g. officecli set <file> <path> --prop key=value");
}
if (props == null || props.Length == 0)
return 2;
}
// `set` is a mutation command; an invocation with no --prop and
// no bare unmatched key=value pairs is a caller mistake (missing
// the property they intended to apply). Without this guard the
// command ran the dispatcher with an empty properties dictionary,
// applied nothing, and returned "Updated <path>" with success=0
// — indistinguishable from a real successful set. Surface as
// missing_property so the caller knows nothing happened.
if ((props == null || props.Length == 0) && unmatchedKvWarnings.Count == 0)
{
var err = $"No properties to set at {path}. Pass at least one --prop key=value.";
if (json)
{
var missingPropWarnings = new List<OfficeCli.Core.CliWarning>
{
new() { Message = err, Code = "missing_property", Suggestion = "--prop key=value" }
};
Console.WriteLine(OutputFormatter.WrapEnvelopeError(err, missingPropWarnings));
}
else
{
Console.Error.WriteLine($"Error: {err}");
}
return 1;
}
// Selector / Excel-native paths (not starting with '/') are accepted:
// handler.Set treats them as a Query→Set-per-match selector, the same
// engine `batch` uses, so `set` is consistent with get/query which
// already take both the XPath form (/Sheet1/A1) and the Excel form
// (Sheet1!A1, Sheet1!row[工资>5000]). Safety rests on the handler
// selector branch throwing on an empty match (no silent no-op) and the
// match-count echo below making a multi-element change visible.
// CONSISTENCY(selector-set): ResidentServer.ExecuteSet mirrors this.
var isSelectorSet = !string.IsNullOrEmpty(path) && !path.StartsWith("/");
// Agent-safety: reject a bare unscoped selector (`cell[...]`, `run`) —
// it would mutate across the whole document. Allows `/`-scoped paths
// and Excel `Sheet1!A1`. query is unaffected. Runs before TryResident
// so the resident-forward path is guarded too.
OfficeCli.Core.MutationSelectorGuard.EnsureScoped(path, "set");
if (TryResident(file.FullName, req =>
{
req.Command = "set";
req.Args["path"] = path;
req.Props = ParsePropsArray(props);
}, json) is {} rc) return rc;
// CONSISTENCY(prop-key-case): --prop keys are case-insensitive
// so "SRC=x" and "src=x" both resolve to the same handler key.
// Reuse ParsePropsArray so the inline and resident-server paths
// stay in sync.
var properties = ParsePropsArray(props);
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: true);
// Scope the unsupported-prop fuzzy-suggestion pool by handler type
// so e.g. Excel pivot errors don't suggest PPTX-only keys like
// 'rotation' for an unknown 'location' prop (R2-4). Kept local: the
// shared core computes its own copy, but the CLI warning / extra-path
// decoration below also needs it.
string? suggestionScope = handler switch
{
OfficeCli.Handlers.ExcelHandler => "excel",
OfficeCli.Handlers.WordHandler => "word",
OfficeCli.Handlers.PowerPointHandler => "pptx",
_ => null,
};
// Shared core: apply + prop-autocorrect + categorise (one copy for
// CLI / batch / MCP / resident; see ApplySetWithCorrection). The rich
// CLI envelope below — find-count, position overlap, --json warnings,
// exit codes — stays here.
var (applied, stillUnsupported, autoCorrected) = ApplySetWithCorrection(handler, path, properties);
// Get find match count if applicable.
// CONSISTENCY(find-match-count): mirrored in ResidentServer.ExecuteSet.
// The resident path is hit whenever a resident process is open
// (which `create` does by default), so both sites must surface
// findMatchCount + zero_matches warning identically.
int? findMatchCount = null;
if (properties.ContainsKey("find"))
{
findMatchCount = handler switch
{
OfficeCli.Handlers.WordHandler wh => wh.LastFindMatchCount,
OfficeCli.Handlers.PowerPointHandler ph => ph.LastFindMatchCount,
OfficeCli.Handlers.ExcelHandler eh => eh.LastFindMatchCount,
_ => null
};
}
// CONSISTENCY(selector-set): echo how many elements a multi-match
// selector set touched so a Sheet1!row[工资>5000]-style change shows
// its scope. Single-target paths (count 1) stay quiet.
int? selectorCount = !isSelectorSet ? null : handler switch
{
OfficeCli.Handlers.WordHandler wh => wh.LastSelectorSetCount,
OfficeCli.Handlers.PowerPointHandler ph => ph.LastSelectorSetCount,
OfficeCli.Handlers.ExcelHandler eh => eh.LastSelectorSetCount,
_ => null
};
// R4-bt-1: an equation mode switch MOVES the element (oMathPara ⇄
// oMath), changing its canonical path. Report the NEW resolvable
// path so the "Updated …" line points at a path that still resolves.
var reportPath = (handler as OfficeCli.Handlers.WordHandler)?.LastSetNewPath ?? path;
var message = applied.Count > 0
? $"Updated {reportPath}: {string.Join(", ", applied.Select(kv => $"{kv.Key}={kv.Value}"))}"
+ (findMatchCount.HasValue ? $" ({findMatchCount.Value} matched)" : "")
+ (selectorCount > 1 ? $" ({selectorCount} elements matched)" : "")
: $"Error: No properties applied to {path}";
// Check if position-related props were changed → show coordinates + overlap warning
var positionChanged = applied.Any(kv => PositionKeys.Contains(kv.Key));
string? setSpatialLine = null;
var setOverlaps = new List<string>();
if (positionChanged)
{
setSpatialLine = GetPptSpatialLine(handler, path);
if (setSpatialLine != null) setOverlaps = CheckPositionOverlap(handler, path);
}
// Unrecognized LaTeX commands/environments from an equation Set
// (formula=). Same UX as unsupported_property (warning + JSON
// envelope + exit 2); the equation is still written (lenient
// accept). CONSISTENCY: mirrors CommandBuilder.Add and
// ResidentServer.ExecuteSet.
var setUnrecognizedLatex = handler switch
{
OfficeCli.Handlers.WordHandler wlx => wlx.LastUnrecognizedLatex,
OfficeCli.Handlers.PowerPointHandler plx => plx.LastUnrecognizedLatex,
_ => null,
};
bool hasUnrecognizedLatex = setUnrecognizedLatex is { Count: > 0 };
if (json)
{
var allWarnings = new List<OfficeCli.Core.CliWarning>();
if (hasUnrecognizedLatex)
{
foreach (var tok in setUnrecognizedLatex!)
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"unrecognized_latex_command: {tok}",
Code = "unrecognized_latex_command",
Suggestion = "Check the command spelling; see https://katex.org/docs/supported.html for supported syntax.",
});
}
if (findMatchCount is 0)
{
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"find pattern matched 0 occurrences at {path} — original text may have been edited or the path is wrong",
Code = "zero_matches",
Suggestion = "verify the path still resolves and the find text is current"
});
}
foreach (var ac in autoCorrected)
{
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"Auto-corrected '{ac.Original}' to '{ac.Corrected}'",
Code = "auto_corrected",
Suggestion = ac.Corrected
});
}
foreach (var p in stillUnsupported)
{
// An entry that already carries a handler-embedded hint
// ("薪水 (no such column; available: …)") must not get a
// generic did-you-mean stacked on top — the handler already
// said what's valid. Mirrors CommandBuilder.FormatUnsupported.
var suggestion = p.Contains('(') ? null : SuggestPropertyScoped(p, suggestionScope);
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = suggestion != null ? $"Unsupported property: {p} (did you mean: {suggestion}?)" : $"Unsupported property: {p}",
Code = "unsupported_property",
Suggestion = suggestion
});
}
if (setOverlaps.Count > 0)
{
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = $"Same position as {string.Join(", ", setOverlaps)}",
Code = "position_overlap",
Suggestion = "Use different x/y values to avoid overlap"
});
}
var setOverflow = CheckTextOverflow(handler, path);
if (setOverflow != null)
{
allWarnings.Add(new OfficeCli.Core.CliWarning
{
Message = setOverflow,
Code = "text_overflow",
Suggestion = "Increase shape height/width, reduce font size, or shorten text"
});
}
if (handler is OfficeCli.Handlers.WordHandler setWhWarn)
{
foreach (var w in setWhWarn.LastSetWarnings)
allWarnings.Add(new OfficeCli.Core.CliWarning { Message = w, Code = "advisory" });
}
var outputMsg = setSpatialLine != null ? $"{message}\n {setSpatialLine}" : message;
// applied==0 implies no key auto-corrected (corrections land in
// applied), so stillUnsupported already equals the raw set, and
// the old `|| unsupported.Count>0` term was redundant.
bool allFailed = applied.Count == 0 && stillUnsupported.Count > 0;
Console.WriteLine(allFailed
? OutputFormatter.WrapEnvelopeError(outputMsg, allWarnings.Count > 0 ? allWarnings : null)
: OutputFormatter.WrapEnvelopeText(outputMsg, allWarnings.Count > 0 ? allWarnings : null, findMatchCount));
}
else
{
foreach (var ac in autoCorrected)
Console.Error.WriteLine($"WARNING: Auto-corrected '{ac.Original}' to '{ac.Corrected}'");
Console.WriteLine(message);
if (findMatchCount is 0)
Console.Error.WriteLine($"WARNING: find pattern matched 0 occurrences at {path}");
if (setSpatialLine != null) Console.WriteLine($" {setSpatialLine}");
if (setOverlaps.Count > 0)
Console.Error.WriteLine($" WARNING: Same position as {string.Join(", ", setOverlaps)}");
var setOverflowPlain = CheckTextOverflow(handler, path);
if (setOverflowPlain != null)
Console.Error.WriteLine($" WARNING: {setOverflowPlain}");
if (stillUnsupported.Count > 0)
Console.Error.WriteLine(FormatUnsupported(stillUnsupported, suggestionScope));
if (handler is OfficeCli.Handlers.WordHandler setWhWarnPlain)
{
foreach (var w in setWhWarnPlain.LastSetWarnings)
Console.Error.WriteLine($" WARNING: {w}");
}
if (hasUnrecognizedLatex)
foreach (var tok in setUnrecognizedLatex!)
Console.Error.WriteLine($" WARNING: unrecognized_latex_command: {tok}");
}
NotifyWatch(handler, file.FullName, path);
// BUG-BT-R5-01: apply the same prop set to the remaining selected
// paths. Each call goes through handler.Set independently so each
// path gets its own auto-correct, find-count, and unsupported list,
// matching the per-path semantics that mark already uses for
// `mark <file> selected`. We collect any non-zero return as an
// error escalation but keep going so partial application is at
// least observable.
if (extraSelectedPaths is not null && extraSelectedPaths.Count > 0)
{
var extraStillUnsupported = false;
foreach (var extraPath in extraSelectedPaths)
{
var extraResult = handler.Set(extraPath, properties);
if (extraResult.Count > 0)
{
extraStillUnsupported = true;
if (!json)
Console.Error.WriteLine($" {extraPath}: {FormatUnsupported(extraResult, suggestionScope)}");
}
NotifyWatch(handler, file.FullName, extraPath);
}
if (extraStillUnsupported && stillUnsupported.Count == 0) return 2;
}
if (stillUnsupported.Count > 0) return 2;
if (hasUnrecognizedLatex) return 2;
return 0;
}, json); });
return setCommand;
}
}
+781
View File
@@ -0,0 +1,781 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildViewCommand(Option<bool> jsonOption)
{
var viewFileArg = new Argument<FileInfo>("file") { Description = "Office document path (.docx, .xlsx, .pptx)" };
var viewModeArg = new Argument<string>("mode") { Description = "View mode: text, annotated, outline, stats, issues, html, svg, screenshot, pdf, forms. text mode (xlsx): each cell is rendered as <A1>=<value>, tab-separated; empty cells are omitted, so a sparse row lists only its populated cells (e.g. 'B2=120000\\tD2=Beijing')." };
var startLineOpt = new Option<int?>("--start") { Description = "Start line/paragraph number" };
var endLineOpt = new Option<int?>("--end") { Description = "End line/paragraph number" };
var maxLinesOpt = new Option<int?>("--max-lines") { Description = "Maximum number of lines/rows/slides to output (truncates with total count)" };
var issueTypeOpt = new Option<string?>("--type") { Description = IssueSubtypes.TypeHelpDescription() };
var limitOpt = new Option<int?>("--limit") { Description = "Limit number of results" };
var colsOpt = new Option<string?>("--cols") { Description = "Column filter, comma-separated (Excel only, e.g. A,B,C)" };
var pageOpt = new Option<string?>("--page") { Description = "Page filter (e.g. 1, 2-5, 1,3,5). html mode: default=all. screenshot mode: default=1 (use --page 1-N to capture more, or --grid N for a whole-doc thumbnail contact sheet)." };
var browserOpt = new Option<bool>("--browser") { Description = "Open output in browser (html / svg modes)" };
var outOpt = new Option<string?>("--out", "-o") { Description = "Output file path (html, screenshot, pdf modes; defaults to stdout for html, a temp file for screenshot)" };
var clipOpt = new Option<string?>("--range") { Description = "Restrict output to a region. Screenshot mode: an xlsx cell range ('Sheet1!A1:C3' or '/Sheet1/A1:C3') or any element data-path ('/slide[1]/shape[@id=N]', '/body/table[1]'); the PNG is cropped to the target's bounding box. Text mode (xlsx only): a cell range or single cell — emits just those rows/cells, saving context on large sheets. Not the character-offset `range=` prop of `set` (that one formats a text span like 3:7)." };
var screenshotWidthOpt = new Option<int>("--screenshot-width") { Description = "Screenshot viewport width (default 1600)", DefaultValueFactory = _ => 1600 };
var screenshotHeightOpt = new Option<int>("--screenshot-height") { Description = "Screenshot viewport height (default 1200)", DefaultValueFactory = _ => 1200 };
var gridOpt = new Option<string?>("--grid")
{
Description = "Tile pages/slides into a thumbnail contact sheet (screenshot mode, pptx + docx). Bare --grid (or --grid auto) picks a column count that keeps the sheet roughly square; pass a number (e.g. --grid 3) to force columns. Omit = off.",
Arity = ArgumentArity.ZeroOrOne, // allow bare --grid (no value) → auto
};
var renderOpt = new Option<string>("--render") { Description = "Screenshot rendering path (docx/pptx): auto (default; native on Windows w/ Word/PowerPoint, html elsewhere), native (force OS-native, error if unavailable), html", DefaultValueFactory = _ => "auto" };
var withPagesOpt = new Option<bool>("--page-count") { Description = "stats mode (docx only): also report total page count via Word repagination (Win + Word required; slow on long docs)" };
var viewCommand = new Command("view", "View document in different modes");
viewCommand.Add(viewFileArg);
viewCommand.Add(viewModeArg);
viewCommand.Add(startLineOpt);
viewCommand.Add(endLineOpt);
viewCommand.Add(maxLinesOpt);
viewCommand.Add(issueTypeOpt);
viewCommand.Add(limitOpt);
viewCommand.Add(colsOpt);
viewCommand.Add(pageOpt);
viewCommand.Add(browserOpt);
viewCommand.Add(outOpt);
viewCommand.Add(clipOpt);
viewCommand.Add(screenshotWidthOpt);
viewCommand.Add(screenshotHeightOpt);
viewCommand.Add(gridOpt);
viewCommand.Add(renderOpt);
viewCommand.Add(withPagesOpt);
viewCommand.Add(jsonOption);
viewCommand.SetAction(result => { var json = result.GetValue(jsonOption); return SafeRun(() =>
{
var file = result.GetValue(viewFileArg)!;
var mode = result.GetValue(viewModeArg)!;
var start = result.GetValue(startLineOpt);
var end = result.GetValue(endLineOpt);
var maxLines = result.GetValue(maxLinesOpt);
var issueType = IssueSubtypes.Validate(result.GetValue(issueTypeOpt));
var limit = result.GetValue(limitOpt);
var colsStr = result.GetValue(colsOpt);
var pageFilter = result.GetValue(pageOpt);
var browser = result.GetValue(browserOpt);
var outArg = result.GetValue(outOpt);
var clipArg = result.GetValue(clipOpt);
var screenshotWidth = result.GetValue(screenshotWidthOpt);
var screenshotHeight = result.GetValue(screenshotHeightOpt);
// --grid has three states: absent → off (0), present with no value
// (bare --grid) → auto (-1), present with a value → parse it.
var gridResult = result.GetResult(gridOpt);
var gridCols = gridResult is null ? 0
: gridResult.Tokens.Count == 0 ? -1
: ParseGridSpec(gridResult.Tokens[0].Value);
var renderMode = (result.GetValue(renderOpt) ?? "auto").ToLowerInvariant();
if (renderMode is not ("auto" or "native" or "html"))
throw new OfficeCli.Core.CliException($"Invalid --render value: {renderMode}. Valid: auto, native, html") { Code = "invalid_render", ValidValues = ["auto", "native", "html"] };
var withPages = result.GetValue(withPagesOpt);
// pdf mode runs entirely through an exporter plugin (no handler
// open, no resident hop — the plugin gets a snapshot of the
// source and writes the PDF). Handled before TryResident
// because exporter invocation needs the file lock released, and
// ExporterInvoker closes the resident itself when present.
if (mode.ToLowerInvariant() is "pdf")
{
var pdfPath = outArg ?? Path.ChangeExtension(file.FullName, "pdf");
var exp = OfficeCli.Core.Plugins.ExporterInvoker.Run(file.FullName, ".pdf", pdfPath);
if (json)
{
Console.WriteLine(OutputFormatter.WrapEnvelopeText(exp.OutputPath));
}
else
{
Console.WriteLine(Path.GetFullPath(exp.OutputPath));
if (exp.ResidentClosed)
Console.Error.WriteLine($"[note] resident closed to release lock; reopen with `officecli open` if needed");
}
if (browser)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo(exp.OutputPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch { /* silently ignore if no default PDF viewer */ }
}
return 0;
}
// Try resident first
if (TryResident(file.FullName, req =>
{
req.Command = "view";
req.Json = json;
req.Args["mode"] = mode;
if (start.HasValue) req.Args["start"] = start.Value.ToString();
if (end.HasValue) req.Args["end"] = end.Value.ToString();
if (maxLines.HasValue) req.Args["max-lines"] = maxLines.Value.ToString();
if (issueType != null) req.Args["type"] = issueType;
if (limit.HasValue) req.Args["limit"] = limit.Value.ToString();
if (colsStr != null) req.Args["cols"] = colsStr;
if (pageFilter != null) req.Args["page"] = pageFilter;
if (browser) req.Args["browser"] = "true";
if (outArg != null) req.Args["out"] = outArg;
if (clipArg != null) req.Args["range"] = clipArg;
req.Args["screenshot-width"] = screenshotWidth.ToString();
req.Args["screenshot-height"] = screenshotHeight.ToString();
if (gridCols != 0) req.Args["grid"] = gridCols.ToString(); // -1 = auto
if (renderMode != "auto") req.Args["render"] = renderMode;
if (withPages) req.Args["page-count"] = "true";
}, json) is {} rc) return rc;
var format = json ? OutputFormat.Json : OutputFormat.Text;
var cols = colsStr != null ? new HashSet<string>(colsStr.Split(',').Select(c => c.Trim().ToUpperInvariant())) : null;
using var handler = DocumentHandlerFactory.Open(file.FullName);
if (mode.ToLowerInvariant() is "html" or "h")
{
string? html = null;
if (handler is OfficeCli.Handlers.PowerPointHandler pptHandler)
{
// BUG-R36-B7: --page on pptx html previously fell through to
// start/end via the parser default (no value), so --page 99
// silently rendered all slides. Honor --page with strict
// range checking, matching SVG mode's CONSISTENCY(strict-page).
var (pStart, pEnd) = ParsePptHtmlPage(pageFilter, start, end, pptHandler);
html = RenderViaRegistry(handler, "pptx",
new OfficeCli.Core.Rendering.RenderOptions { StartPage = pStart, EndPage = pEnd });
}
else if (handler is OfficeCli.Handlers.ExcelHandler)
html = RenderViaRegistry(handler, "xlsx", new OfficeCli.Core.Rendering.RenderOptions());
else if (handler is OfficeCli.Handlers.WordHandler)
html = RenderViaRegistry(handler, "docx",
new OfficeCli.Core.Rendering.RenderOptions { PageFilter = pageFilter });
else if (handler is OfficeCli.Core.Plugins.FormatHandlerProxy proxy)
html = proxy.ViewAsHtml(int.TryParse(pageFilter, out var p) ? p : (int?)null);
if (html != null)
{
if (outArg != null || browser)
{
// --out: write to the requested path. --browser without --out:
// write to a temp file and open it. With both, write to --out
// and open that.
// SECURITY: when falling back to a temp file, include a random
// token so the preview path is not predictable. A predictable
// path (HHmmss only) lets a local attacker pre-place a symlink at
// the expected location, causing File.WriteAllText to follow it
// and overwrite an arbitrary victim file with preview HTML. It
// also caused collisions between concurrent `view html`
// invocations of the same file.
var htmlPath = outArg ?? Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.html");
File.WriteAllText(htmlPath, html);
Console.WriteLine(Path.GetFullPath(htmlPath));
if (browser)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo(htmlPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch { /* silently ignore if browser can't be opened */ }
}
}
else
{
// Default: output HTML to stdout
Console.Write(html);
}
}
else
{
throw new OfficeCli.Core.CliException("HTML preview is only supported for .pptx, .xlsx, and .docx files.")
{
Code = "unsupported_type",
Suggestion = "Use a .pptx, .xlsx, or .docx file, or use mode 'text' or 'annotated' for other formats.",
ValidValues = ["text", "annotated", "outline", "stats", "issues"]
};
}
return 0;
}
if (mode.ToLowerInvariant() is "screenshot" or "p")
{
// Screenshot mode: render the same HTML preview as `view html`, then
// headless-screenshot the temp HTML to a PNG. Mirrors svg's pattern of
// a dedicated mode that produces a file + prints the path.
// --grid N tiles slides into an N-column thumbnail grid (pptx only).
//
// CONSISTENCY(screenshot-default-first-page): screenshot mode defaults
// to a single bounded visual unit (pptx → slide 1, docx → page 1, xlsx
// → active sheet). Without this, multi-slide/multi-page docs render
// the full HTML stacked vertically and get silently cropped by the
// viewport height (default 1200) — a footgun. To capture all
// slides/pages, use --page explicitly (e.g. --page 1-N) or --grid N
// for pptx thumbnails. xlsx is naturally first-sheet via CSS
// `.sheet-content { display:none }` + `.active` on sheet 0.
string? html = null;
byte[]? directPng = null;
// --clip captures a data-path-addressed region out of the HTML
// preview, so the native/direct-PNG backends (whole pages only)
// are bypassed. pptx: the clip's slide ordinal selects the page
// when --page wasn't given; docx: all pages render so the target
// can sit anywhere (--page still narrows explicitly).
if (clipArg != null)
renderMode = "html";
if (handler is OfficeCli.Handlers.PowerPointHandler pptHandler)
{
var effectiveFilter = pageFilter;
if (clipArg != null && string.IsNullOrEmpty(effectiveFilter)
&& System.Text.RegularExpressions.Regex.Match(clipArg, @"^/slide\[(\d+)\]") is { Success: true } slideM)
effectiveFilter = slideM.Groups[1].Value;
if (string.IsNullOrEmpty(effectiveFilter) && start is null && end is null && gridCols == 0)
effectiveFilter = "1";
var (pStart, pEnd) = ParsePptHtmlPage(effectiveFilter, start, end, pptHandler);
// Native path (mirrors docx --render auto/native/html): on Windows
// with the presentation app installed, export the slide(s) to PNG
// with the OS-native engine. Grid mode is HTML-only, so native is
// skipped when --grid is set. The slide's 96-DPI native pixels are
// the default export size; a custom --screenshot-width overrides it
// (aspect-matched height). A range stacks vertically.
var (nativeW, nativeH) = pptHandler.GetSlideNativePixels();
// -1 = auto: pick columns from the slide count + slide aspect so the
// composed contact sheet is ≈ square (landscape slides → fewer cols).
int gridColsResolved = gridCols < 0
? OfficeCli.Core.HtmlScreenshot.AutoGridColumns((pEnd ?? pptHandler.GetSlideCount()) - (pStart ?? 1) + 1, nativeW, nativeH)
: gridCols;
int exportW = nativeW, exportH = nativeH;
if (!(screenshotWidth == 1600 && screenshotHeight == 1200))
{
exportW = screenshotWidth;
exportH = screenshotHeight == 1200
? Math.Max(1, (int)Math.Round(screenshotWidth * (double)nativeH / nativeW))
: screenshotHeight;
}
if (renderMode != "html" && OperatingSystem.IsWindows())
{
try
{
if (gridColsResolved > 0)
{
const int gap = 12, pad = 12;
int cellW = Math.Max(1, (int)Math.Round((screenshotWidth - 2 * pad - (gridColsResolved - 1) * gap) / (double)gridColsResolved));
int cellH = Math.Max(1, (int)Math.Round(cellW * (double)nativeH / nativeW));
directPng = OfficeCli.Core.PowerPointPngBackend.RenderGrid(file.FullName, pStart ?? 1, pEnd ?? pptHandler.GetSlideCount(), cellW, cellH, gridColsResolved, gap, pad);
}
else
{
directPng = OfficeCli.Core.PowerPointPngBackend.Render(file.FullName, pStart ?? 1, pEnd ?? pStart ?? 1, exportW, exportH);
}
}
catch { directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft PowerPoint installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
if (directPng == null)
{
html = RenderViaRegistry(pptHandler, "pptx", new OfficeCli.Core.Rendering.RenderOptions
{ StartPage = pStart, EndPage = pEnd, GridColumns = gridColsResolved, ViewportPx = screenshotWidth })!;
// The generic 4:3 viewport (1600×1200) letterboxes a single slide with
// canvas padding. When capturing one slide (not a multi-slide range or
// grid), size the viewport to the slide so the PNG is the slide,
// padding-free (ViewAsHtml scales the slide to fill + zeroes the headless
// page padding). Default dims -> the slide's 96-DPI native pixels;
// a custom --screenshot-width -> that width with an aspect-matched height.
// Multi-slide ranges stack vertically and keep the tall viewport.
if (pStart == pEnd && gridCols == 0)
{
if (screenshotWidth == 1600 && screenshotHeight == 1200)
(screenshotWidth, screenshotHeight) = (nativeW, nativeH);
else if (screenshotHeight == 1200)
screenshotHeight = Math.Max(1, (int)Math.Round(screenshotWidth * (double)nativeH / nativeW));
}
}
}
else if (handler is OfficeCli.Handlers.ExcelHandler excelHandler)
html = RenderViaRegistry(excelHandler, "xlsx", new OfficeCli.Core.Rendering.RenderOptions())!;
else if (handler is OfficeCli.Handlers.WordHandler wordHandlerGrid && gridCols != 0)
{
// Contact-sheet grid: tile every page into an N-column (or auto)
// thumbnail grid for a one-shot whole-document overview.
// Native-first, mirroring pptx and single-page docx: on Windows
// with Word, RenderGrid rasterizes each real-Word page and tiles
// it; elsewhere (or on failure) we fall back to the HTML preview
// grid. Column count + cell size are computed once below and used
// by both paths.
const int gap = 12, pad = 12;
const int maxDim = 1920; // mirror HtmlScreenshot.CapDim's LLM-image ceiling
const int scrollbar = 17;
var (npW, npH) = wordHandlerGrid.GetPageNativePixels();
// Page count needs a real layout pass; the preview's paginator
// publishes it via <title>PAGES:N> on dump-dom (independent of the
// grid, which only reflows AFTER pagination). Count first so
// `--grid auto` can size the column count to the document.
int pageCount = 1;
var tmpForCount = Path.Combine(Path.GetTempPath(), $"officecli_gridcount_{Path.GetFileNameWithoutExtension(file.Name)}_{Guid.NewGuid():N}.html");
try
{
File.WriteAllText(tmpForCount, RenderViaRegistry(wordHandlerGrid, "docx", new OfficeCli.Core.Rendering.RenderOptions())!);
pageCount = OfficeCli.Core.HtmlScreenshot.GetPageCountFromDom(tmpForCount) ?? 1;
}
catch { /* fall back to 1 row */ }
finally { try { File.Delete(tmpForCount); } catch { /* ignore */ } }
// -1 = auto: pick columns that keep the composed sheet ≈ square.
int docGridCols = gridCols < 0 ? OfficeCli.Core.HtmlScreenshot.AutoGridColumns(pageCount, npW, npH) : gridCols;
int rows = Math.Max(1, (pageCount + docGridCols - 1) / docGridCols);
// Pre-cap to the 1920 ceiling OURSELVES and recompute cellW from the
// final width, so cellW and the capture viewport stay consistent
// (CapDim shrinking the viewport while cellW stayed fixed is what
// collapsed a 3-col request to 2 cols). After this, CapDim is a no-op.
// Subtract a scrollbar allowance so this matches layoutGrid's
// clientWidth-derived cell size → the row-height estimate (and thus
// the captured viewport) tracks the real grid with little slack.
double vpW = screenshotWidth;
double cellW = Math.Max(1.0, (vpW - scrollbar - 2.0 * pad - (docGridCols - 1) * gap) / docGridCols);
double cellH = cellW * npH / npW;
double vpH = pad * 2 + rows * cellH + (rows - 1) * gap;
double over = Math.Max(vpW, vpH) / maxDim;
if (over > 1.0) { vpW /= over; cellW /= over; cellH /= over; vpH /= over; }
// Native-first: render each real-Word page and tile (Windows + Word).
if (renderMode != "html" && OperatingSystem.IsWindows())
{
try { directPng = OfficeCli.Core.WordPdfBackend.RenderGrid(file.FullName, $"1-{pageCount}", (int)Math.Round(cellW), (int)Math.Round(cellH), docGridCols, gap, pad); }
catch { directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft Word installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
if (directPng == null)
{
// HTML fallback: layoutGrid tiles in-browser; size the viewport
// to fit the rows so window-size backends don't crop.
html = RenderViaRegistry(wordHandlerGrid, "docx", new OfficeCli.Core.Rendering.RenderOptions
{ GridColumns = docGridCols, GridCellWidthPx = (int)Math.Round(cellW) })!;
screenshotWidth = Math.Max(1, (int)Math.Round(vpW));
screenshotHeight = Math.Max(1, (int)Math.Ceiling(vpH));
}
}
else if (handler is OfficeCli.Handlers.WordHandler wordHandler)
{
// --clip: render every page (filter stays null) unless the
// caller narrowed with --page — the target may be anywhere.
var effectiveFilter = clipArg != null
? pageFilter
: (string.IsNullOrEmpty(pageFilter) ? "1" : pageFilter);
if (renderMode != "html" && OperatingSystem.IsWindows())
{
// effectiveFilter is only null under --range, which forces
// renderMode=html — this native branch is then unreachable.
try { directPng = OfficeCli.Core.WordPdfBackend.Render(file.FullName, effectiveFilter!); }
catch { directPng = null; }
}
if (renderMode == "native" && directPng == null)
throw new OfficeCli.Core.CliException("--render native requires Windows with Microsoft Word installed.")
{ Code = "native_unavailable", Suggestion = "Use --render html or --render auto." };
if (directPng == null)
{
html = RenderViaRegistry(wordHandler, "docx",
new OfficeCli.Core.Rendering.RenderOptions { PageFilter = effectiveFilter })!;
// HTML-path screenshot of a single page: size the viewport to the
// page's 96-DPI native pixels so the PNG is the page, padding-free
// (the preview scales the page to fill + drops its chrome when
// headless). Default dims -> native px; a custom --screenshot-width
// -> that width with an aspect-matched height. The Windows COM path
// (directPng != null) renders real pages and is untouched.
if (int.TryParse(effectiveFilter, out _) && gridCols == 0)
{
var (nativeW, nativeH) = wordHandler.GetPageNativePixels();
if (screenshotWidth == 1600 && screenshotHeight == 1200)
(screenshotWidth, screenshotHeight) = (nativeW, nativeH);
else if (screenshotHeight == 1200)
screenshotHeight = Math.Max(1, (int)Math.Round(screenshotWidth * (double)nativeH / nativeW));
}
}
}
// A renderer that paints its own pixels supplies them directly, sitting
// between the native backend (which wins when it ran) and the HTML→headless
// fallback. An explicit --render html opts out. The default install has no
// PNG-capable renderer, so this is a no-op there.
if (directPng == null && renderMode != "html")
{
var pngFmt = handler switch
{
OfficeCli.Handlers.PowerPointHandler => "pptx",
OfficeCli.Handlers.ExcelHandler => "xlsx",
OfficeCli.Handlers.WordHandler => "docx",
_ => null
};
if (pngFmt != null)
directPng = RenderPngBytesViaRegistry(handler, pngFmt,
new OfficeCli.Core.Rendering.RenderOptions
{
Output = OfficeCli.Core.Rendering.RenderOutputKind.Png,
PageFilter = pageFilter,
RasterWidthPx = screenshotWidth,
RasterHeightPx = screenshotHeight,
});
}
if (html == null && directPng == null)
{
throw new OfficeCli.Core.CliException("Screenshot mode is only supported for .pptx, .xlsx, and .docx files.")
{
Code = "unsupported_type",
Suggestion = "Use a .pptx, .xlsx, or .docx file.",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot"]
};
}
var pngPath = outArg ?? Path.Combine(Path.GetTempPath(), $"officecli_screenshot_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.png");
if (directPng != null)
{
File.WriteAllBytes(pngPath, directPng);
}
else
{
// SECURITY: random token in temp filename — same rationale as the html/--browser path.
var tmpHtml = Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.html");
File.WriteAllText(tmpHtml, html!);
var r = clipArg != null
? OfficeCli.Core.HtmlScreenshot.CaptureClipped(tmpHtml, pngPath,
OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(clipArg))
: OfficeCli.Core.HtmlScreenshot.Capture(tmpHtml, pngPath, screenshotWidth, screenshotHeight);
try { File.Delete(tmpHtml); } catch { /* ignore */ }
if (!r.Ok && r.Error == "clip_target_not_found")
{
throw new OfficeCli.Core.CliException(
$"--range target '{clipArg}' matched no rendered element.")
{
Code = "range_target_not_found",
Suggestion = "Use a data-path the HTML preview emits (xlsx: 'Sheet1!A1:C3' within the sheet's used area; pptx: '/slide[N]/shape[K]'; docx: '/body/table[N]' or '/body/p[N]'). `query` lists element paths.",
};
}
if (!r.Ok)
{
throw new OfficeCli.Core.CliException(
"No headless browser available. Install Chrome/Edge/Chromium or Firefox, or `pip install playwright && playwright install chromium`."
+ (r.Error != null ? $" Last error: {r.Error}" : ""))
{ Code = "no_screenshot_backend" };
}
}
Console.WriteLine(Path.GetFullPath(pngPath));
if (handler is OfficeCli.Handlers.PowerPointHandler pptCount)
Console.Error.WriteLine($"[pages] total={pptCount.GetSlideCount()}");
if (browser)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo(pngPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch { /* silently ignore if image viewer can't be opened */ }
}
return 0;
}
if (mode.ToLowerInvariant() is "svg" or "g")
{
if (handler is OfficeCli.Handlers.PowerPointHandler pptSvgHandler)
{
// CONSISTENCY(view-page): SVG mode honors --page like html mode; --page wins over --start
int slideNum = 1;
if (!string.IsNullOrEmpty(pageFilter))
{
var firstTok = pageFilter.Split(',')[0].Split('-')[0].Trim();
// CONSISTENCY(strict-page): reject non-positive --page
// values explicitly instead of silently rendering
// slide 1, mirroring how 0 / negatives are surfaced
// elsewhere in the CLI.
if (!int.TryParse(firstTok, out var p))
throw new ArgumentException(
$"Invalid --page value '{pageFilter}': expected a positive slide number.");
if (p <= 0)
throw new ArgumentException(
$"Invalid --page value '{pageFilter}': slide number must be >= 1.");
slideNum = p;
}
else if (start.HasValue && start.Value > 0)
{
slideNum = start.Value;
}
var svg = RenderViaRegistry(handler, "pptx",
new OfficeCli.Core.Rendering.RenderOptions
{ Output = OfficeCli.Core.Rendering.RenderOutputKind.Svg, StartPage = slideNum })!;
if (browser)
{
string outPath;
if (svg.Contains("data-formula"))
{
// Wrap SVG in HTML shell for KaTeX formula rendering
// GUID keeps the path unpredictable so a local attacker
// can't pre-plant a symlink at it and have WriteAllText
// clobber a victim file (CWE-59) — matches the sibling
// preview/screenshot temp writers in this file.
outPath = Path.Combine(Path.GetTempPath(), $"officecli_slide{slideNum}_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.html");
// CONSISTENCY(katex-mirror): mirror-first with CDN fallback — see Core/KatexAssets.
var html = $"<!DOCTYPE html><html><head><meta charset='UTF-8'><link rel='stylesheet' href='{OfficeCli.Core.KatexAssets.CssUrl}' onerror=\"{OfficeCli.Core.KatexAssets.CssOnErrorJs}\"><script defer src='{OfficeCli.Core.KatexAssets.JsUrl}' onerror=\"{OfficeCli.Core.KatexAssets.JsOnErrorJs("")}\"></script><style>body{{margin:0;display:flex;justify-content:center;background:#f0f0f0}}</style></head><body>{svg}<script>window.addEventListener('load',function(){{document.querySelectorAll('[data-formula]').forEach(function(el){{try{{katex.render(el.getAttribute('data-formula'),el,{{throwOnError:false,displayMode:true}})}}catch(e){{}}}})}})</script></body></html>";
File.WriteAllText(outPath, html);
}
else
{
outPath = Path.Combine(Path.GetTempPath(), $"officecli_slide{slideNum}_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.svg");
File.WriteAllText(outPath, svg);
}
Console.WriteLine(outPath);
try
{
var psi = new System.Diagnostics.ProcessStartInfo(outPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch { /* silently ignore if browser can't be opened */ }
}
else
{
Console.Write(svg);
}
}
else if (handler is OfficeCli.Core.Plugins.FormatHandlerProxy svgProxy)
{
int? svgPage = null;
if (!string.IsNullOrEmpty(pageFilter)
&& int.TryParse(pageFilter.Split(',')[0].Split('-')[0].Trim(), out var sp))
svgPage = sp;
var svg = svgProxy.ViewAsSvg(svgPage);
if (svg is null)
throw new OfficeCli.Core.CliException(
$"SVG preview is not supported by the format-handler plugin for {file.Extension}.")
{ Code = "unsupported_type" };
if (browser)
{
var outPath = Path.Combine(Path.GetTempPath(),
$"officecli_preview_{Path.GetFileNameWithoutExtension(file.Name)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.svg");
File.WriteAllText(outPath, svg);
Console.WriteLine(outPath);
try
{
var psi = new System.Diagnostics.ProcessStartInfo(outPath) { UseShellExecute = true };
System.Diagnostics.Process.Start(psi);
}
catch { /* silently ignore if viewer can't be opened */ }
}
else
{
Console.Write(svg);
}
}
else
{
throw new OfficeCli.Core.CliException("SVG preview is only supported for .pptx files.")
{
Code = "unsupported_type",
Suggestion = "Use a .pptx file, or use mode 'text' or 'annotated' for other formats.",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot"]
};
}
return 0;
}
int? withPagesValue = null;
if (withPages && (mode.ToLowerInvariant() is "stats" or "s") && handler is OfficeCli.Handlers.WordHandler wordHandlerForCount)
{
if (OperatingSystem.IsWindows())
{
try { withPagesValue = OfficeCli.Core.WordPdfBackend.GetPageCount(file.FullName); } catch { withPagesValue = null; }
}
if (withPagesValue == null)
{
var tmpHtml = Path.Combine(Path.GetTempPath(), $"officecli_pc_{Path.GetFileNameWithoutExtension(file.Name)}_{Guid.NewGuid():N}.html");
try
{
File.WriteAllText(tmpHtml, RenderViaRegistry(wordHandlerForCount, "docx", new OfficeCli.Core.Rendering.RenderOptions())!);
withPagesValue = OfficeCli.Core.HtmlScreenshot.GetPageCountFromDom(tmpHtml);
}
finally { try { File.Delete(tmpHtml); } catch { } }
}
if (withPagesValue == null)
throw new OfficeCli.Core.CliException("--page-count: failed to get page count (Word backend and HTML fallback both unavailable).")
{ Code = "page_count_unavailable" };
}
if (json)
{
// Structured JSON output — no Content string wrapping
var modeKey = mode.ToLowerInvariant();
if (modeKey is "stats" or "s")
{
var statsJson = handler.ViewAsStatsJson();
if (withPagesValue.HasValue) statsJson["pages"] = withPagesValue.Value;
Console.WriteLine(OutputFormatter.WrapEnvelope(statsJson.ToJsonString(OutputFormatter.PublicJsonOptions)));
}
else if (modeKey is "outline" or "o")
Console.WriteLine(OutputFormatter.WrapEnvelope(handler.ViewAsOutlineJson().ToJsonString(OutputFormatter.PublicJsonOptions)));
else if (modeKey is "text" or "t")
Console.WriteLine(OutputFormatter.WrapEnvelope(handler.ViewAsTextJson(start, end, maxLines, cols, clipArg).ToJsonString(OutputFormatter.PublicJsonOptions)));
else if (modeKey is "annotated" or "a")
Console.WriteLine(OutputFormatter.WrapEnvelope(
OutputFormatter.FormatView(mode, handler.ViewAsAnnotated(start, end, maxLines, cols), OutputFormat.Json)));
else if (modeKey is "issues" or "i")
Console.WriteLine(OutputFormatter.WrapEnvelope(
OutputFormatter.FormatIssues(handler.ViewAsIssues(issueType, limit), OutputFormat.Json)));
else if (modeKey is "forms" or "f")
{
if (handler is OfficeCli.Handlers.WordHandler wordFormsHandler)
Console.WriteLine(OutputFormatter.WrapEnvelope(wordFormsHandler.ViewAsFormsJson().ToJsonString(OutputFormatter.PublicJsonOptions)));
else if (handler is OfficeCli.Core.Plugins.FormatHandlerProxy formsProxy)
{
var formsJson = formsProxy.ViewAsFormsJson();
if (formsJson is null)
throw new OfficeCli.Core.CliException($"Forms view is not supported by the format-handler plugin for {file.Extension}.")
{ Code = "unsupported_type" };
Console.WriteLine(OutputFormatter.WrapEnvelope(formsJson.ToJsonString(OutputFormatter.PublicJsonOptions)));
}
else
throw new OfficeCli.Core.CliException("Forms view is only supported for .docx files.")
{
Code = "unsupported_type",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot", "pdf", "forms"]
};
}
else
throw new OfficeCli.Core.CliException($"Unknown mode: {mode}. Available: text, annotated, outline, stats, issues, html, svg, screenshot, forms")
{
Code = "invalid_value",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot", "pdf", "forms"]
};
}
else
{
var output = mode.ToLowerInvariant() switch
{
"text" or "t" => handler.ViewAsText(start, end, maxLines, cols, clipArg),
"annotated" or "a" => handler.ViewAsAnnotated(start, end, maxLines, cols),
"outline" or "o" => handler.ViewAsOutline(),
"stats" or "s" => withPagesValue.HasValue
? $"Pages: {withPagesValue}\n" + handler.ViewAsStats()
: handler.ViewAsStats(),
"issues" or "i" => OutputFormatter.FormatIssues(handler.ViewAsIssues(issueType, limit), OutputFormat.Text),
"forms" or "f" => handler switch
{
OfficeCli.Handlers.WordHandler wfh => wfh.ViewAsForms(),
OfficeCli.Core.Plugins.FormatHandlerProxy fp
=> fp.ViewAsFormsJson()?.ToJsonString(OutputFormatter.PublicJsonOptions)
?? throw new OfficeCli.Core.CliException($"Forms view is not supported by the format-handler plugin for {file.Extension}.")
{ Code = "unsupported_type" },
_ => throw new OfficeCli.Core.CliException("Forms view is only supported for .docx files.")
{
Code = "unsupported_type",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot", "pdf", "forms"]
}
},
_ => throw new OfficeCli.Core.CliException($"Unknown mode: {mode}. Available: text, annotated, outline, stats, issues, html, svg, screenshot, forms")
{
Code = "invalid_value",
ValidValues = ["text", "annotated", "outline", "stats", "issues", "html", "svg", "screenshot", "pdf", "forms"]
}
};
Console.WriteLine(output);
}
return 0;
}, json); });
return viewCommand;
}
/// <summary>
/// BUG-R36-B7 helper. Resolve --page (and fallback --start/--end) into a
/// validated (startSlide, endSlide) pair for pptx html previews. Rejects
/// non-positive numbers and indices past the slide count instead of
/// silently rendering the whole deck.
/// </summary>
// Interpret the --grid value: absent/empty → 0 (off), "auto" → -1 (pick a
// column count that keeps the sheet roughly square), a non-negative integer
// → that explicit column count. Anything else is a hard error.
private static int ParseGridSpec(string? spec)
{
if (string.IsNullOrWhiteSpace(spec)) return 0;
spec = spec.Trim();
if (spec.Equals("auto", StringComparison.OrdinalIgnoreCase)) return -1;
if (int.TryParse(spec, out var n) && n >= 0) return n;
throw new OfficeCli.Core.CliException($"Invalid --grid value: {spec}. Use a column count (e.g. 3) or 'auto'.")
{ Code = "invalid_value", ValidValues = ["auto", "1", "2", "3", "4"] };
}
// Render through the renderer registry rather than calling the handler directly.
// The built-in renderers forward to the handler's ViewAs* methods (output is
// byte-identical), so this is behavior-preserving; it also lets an alternative
// renderer registered for the format be selected instead. Returns null when no
// renderer covers the request, preserving the unsupported-type path.
internal static string? RenderViaRegistry(
OfficeCli.Core.IDocumentHandler handler, string formatId,
OfficeCli.Core.Rendering.RenderOptions options)
{
OfficeCli.Handlers.Rendering.RenderingBootstrap.EnsureRegistered();
var renderer = OfficeCli.Core.Rendering.RendererRegistry.Default
.Resolve(formatId, options.Output, options.Mode);
return renderer?.Render(
new OfficeCli.Handlers.Rendering.HandlerRenderInput(handler, formatId), options).Text;
}
// PNG bytes from a renderer that paints its own pixels, or null when none is
// registered for this format (the built-in renderers emit HTML only, so the
// default install returns null here and the HTML→headless path is used). The
// native (real Office) backend, when it ran, has already produced the pixels and
// this is not reached.
internal static byte[]? RenderPngBytesViaRegistry(
OfficeCli.Core.IDocumentHandler handler, string formatId,
OfficeCli.Core.Rendering.RenderOptions options)
{
OfficeCli.Handlers.Rendering.RenderingBootstrap.EnsureRegistered();
var renderer = OfficeCli.Core.Rendering.RendererRegistry.Default
.Resolve(formatId, OfficeCli.Core.Rendering.RenderOutputKind.Png, options.Mode);
if (renderer == null) return null;
return renderer.Render(
new OfficeCli.Handlers.Rendering.HandlerRenderInput(handler, formatId), options).Bytes;
}
private static (int? start, int? end) ParsePptHtmlPage(
string? pageFilter, int? start, int? end,
OfficeCli.Handlers.PowerPointHandler pptHandler)
{
if (string.IsNullOrEmpty(pageFilter)) return (start, end);
var slideCount = pptHandler.Query("slide").Count;
var firstTok = pageFilter.Split(',')[0].Trim();
// Range form "M-N"
if (firstTok.Contains('-'))
{
var parts = firstTok.Split('-', 2);
if (!int.TryParse(parts[0], out var ps) || !int.TryParse(parts[1], out var pe))
throw new ArgumentException($"Invalid --page value '{pageFilter}': expected N or M-N or comma list.");
if (ps <= 0 || pe <= 0)
throw new ArgumentException($"Invalid --page value '{pageFilter}': slide number must be >= 1.");
if (ps > slideCount)
throw new ArgumentException($"--page {ps} out of range (total slides: {slideCount}).");
return (ps, Math.Min(pe, slideCount));
}
if (!int.TryParse(firstTok, out var p))
throw new ArgumentException($"Invalid --page value '{pageFilter}': expected a positive slide number.");
if (p <= 0)
throw new ArgumentException($"Invalid --page value '{pageFilter}': slide number must be >= 1.");
if (p > slideCount)
throw new ArgumentException($"--page {p} out of range (total slides: {slideCount}).");
return (p, p);
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.CommandLine;
using OfficeCli.Core;
using OfficeCli.Handlers;
namespace OfficeCli;
static partial class CommandBuilder
{
private static Command BuildWatchCommand(Option<bool> jsonOption)
{
var watchFileArg = new Argument<FileInfo>("file") { Description = "Office document path (.pptx, .xlsx, .docx)" };
var watchPortOpt = new Option<int>("--port") { Description = "HTTP port for preview server" };
watchPortOpt.DefaultValueFactory = _ => 26315;
var watchCommand = new Command("watch", "Start a live preview server that refreshes when officecli modifies the document (external edits are not detected). Subcommands (mark/unmark/marks/goto) operate on the running preview.");
watchCommand.Add(watchFileArg);
watchCommand.Add(watchPortOpt);
// Subcommands — operate against the running watch process via named-pipe IPC.
// These were previously top-level (`mark`, `unmark`, `get-marks`, `goto`);
// grouped under `watch` to reflect that they only function while a watch
// session is alive. The top-level forms remain registered as hidden BC
// aliases (see CommandBuilder.cs).
watchCommand.Add(BuildMarkCommand(jsonOption, "mark"));
watchCommand.Add(BuildUnmarkMarkCommand(jsonOption, "unmark"));
watchCommand.Add(BuildGetMarksCommand(jsonOption, "marks"));
watchCommand.Add(BuildGotoCommand(jsonOption, "goto"));
watchCommand.SetAction(result => SafeRun(() =>
{
var file = result.GetValue(watchFileArg)!;
var port = result.GetValue(watchPortOpt);
// Render initial HTML: ask the resident process if one is running,
// otherwise open the file directly as a fallback.
string? initialHtml = null;
if (file.Exists)
{
// Try resident first — avoids file lock conflict.
// Json=true makes resident return raw HTML via Console.Write;
// the resident then wraps it in a JSON envelope { "success":true, "message":"<html>..." }.
var resp = ResidentClient.TrySend(file.FullName,
new ResidentRequest { Command = "view", Args = new() { ["mode"] = "html" }, Json = true },
connectTimeoutMs: 2000);
if (resp is { ExitCode: 0 } && !string.IsNullOrEmpty(resp.Stdout))
{
try
{
using var doc = System.Text.Json.JsonDocument.Parse(resp.Stdout);
if (doc.RootElement.TryGetProperty("message", out var msg))
initialHtml = msg.GetString();
}
catch { /* parse failed — fall through to direct open */ }
}
else
{
// No resident — open directly
try
{
using var handler = DocumentHandlerFactory.Open(file.FullName, editable: false);
if (handler is OfficeCli.Handlers.PowerPointHandler)
initialHtml = RenderViaRegistry(handler, "pptx", new OfficeCli.Core.Rendering.RenderOptions());
else if (handler is OfficeCli.Handlers.ExcelHandler)
initialHtml = RenderViaRegistry(handler, "xlsx", new OfficeCli.Core.Rendering.RenderOptions());
else if (handler is OfficeCli.Handlers.WordHandler)
initialHtml = RenderViaRegistry(handler, "docx", new OfficeCli.Core.Rendering.RenderOptions());
}
catch (Exception ex)
{
Console.Error.WriteLine($"Warning: initial render failed — preview will show 'Waiting for first update' until the next document change.");
Console.Error.WriteLine($" {ex.GetType().Name}: {ex.Message}");
if (Environment.GetEnvironmentVariable("OFFICECLI_DEBUG") == "1" && ex.StackTrace != null)
Console.Error.WriteLine(ex.StackTrace);
}
}
}
using var cts = new CancellationTokenSource();
using var watch = new WatchServer(file.FullName, port, initialHtml: initialHtml);
// Signal handling (SIGTERM / SIGINT / SIGHUP / SIGQUIT) is
// now registered inside WatchServer.RunAsync via
// PosixSignalRegistration, which runs BEFORE the .NET runtime
// begins its shutdown sequence (on a healthy ThreadPool).
// That path runs StopAsync to completion — including
// TcpListener.Stop() (the only reliable way to unstick
// AcceptTcpClientAsync on macOS) and the CoreFxPipe_ socket
// cleanup (BUG-BT-003) — before calling Environment.Exit.
//
// The older Console.CancelKeyPress + ProcessExit combo was
// unreliable: SIGINT would cancel _cts but the TCP accept
// loop did not honour cancellation on macOS, hanging the
// process for 15+ seconds; ProcessExit ran during runtime
// teardown when ThreadPool was already unwinding, so the
// socket cleanup silently skipped.
watch.RunAsync(cts.Token).GetAwaiter().GetResult();
return 0;
}));
return watchCommand;
}
private static Command BuildUnwatchCommand()
{
var unwatchFileArg = new Argument<FileInfo>("file") { Description = "Office document path (.pptx, .xlsx, .docx)" };
var unwatchCommand = new Command("unwatch", "Stop the watch preview server for the document");
unwatchCommand.Add(unwatchFileArg);
unwatchCommand.SetAction(result => SafeRun(() =>
{
var file = result.GetValue(unwatchFileArg)!;
if (WatchNotifier.SendClose(file.FullName))
Console.WriteLine($"Watch stopped for {file.Name}");
else
Console.Error.WriteLine($"No watch running for {file.Name}");
return 0;
}));
return unwatchCommand;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.Json;
namespace OfficeCli.Core;
/// <summary>
/// Public entry point for running a batch against an already-open
/// <see cref="IDocumentHandler"/> without going through a CLI process or
/// resident pipe — for embedders that link officecli directly (in-process
/// hosts). Reuses the same batch-item dispatch and JSON
/// envelope shape as the CLI `batch` command and the Node/Python SDKs, so a
/// caller sees one consistent protocol regardless of transport.
/// </summary>
public static class BatchExecutor
{
/// <summary>
/// Runs a batch and returns EXACTLY what the CLI `batch` command writes to
/// stdout, so an in-process host is byte-identical to the CLI (and to
/// what the watch server relays): with <paramref name="json"/> the
/// `{success, data:{results, summary}}` envelope, without it the
/// `[i] ...\n\nBatch complete: ...` text. This is the same output path the
/// CLI command uses (RunNonResidentBatch → PrintBatchResults → WrapEnvelope,
/// see CommandBuilder.Batch.cs); the earlier shortcut here emitted a bare
/// results array with `json` hardcoded, diverging from every other transport.
/// </summary>
/// <param name="itemsJson">A JSON array of batch items — the same shape documented
/// for the CLI `batch` command and the `BatchItem` SDK type.</param>
/// <param name="json">Mirrors the CLI `--json` toggle: structured envelope vs plain text.</param>
public static string ExecuteBatch(IDocumentHandler handler, string itemsJson, bool json, bool stopOnError = false)
{
try
{
var items = JsonSerializer.Deserialize(itemsJson, BatchJsonContext.Default.ListBatchItem) ?? new List<BatchItem>();
var unrecognizedLatex = new List<string>();
var results = CommandBuilder.RunNonResidentBatch(handler, items, stopOnError, json, unrecognizedLatex);
// Reuse the CLI's own body formatter so both modes are byte-identical.
using var sw = new System.IO.StringWriter();
CommandBuilder.PrintBatchResults(results, json, items.Count, sw);
var inner = sw.ToString().TrimEnd('\n', '\r');
if (!json) return inner;
// Same outer envelope the CLI batch command applies: unrecognized-LaTeX
// warnings + outer success true only when every step succeeded
// (CommandBuilder.Batch.cs). Judgment command — a single failed step
// flips outer success to false.
var warnings = new List<CliWarning>();
foreach (var tok in unrecognizedLatex)
warnings.Add(new CliWarning
{
Message = $"unrecognized_latex_command: {tok}",
Code = "unrecognized_latex_command",
Suggestion = "Check the command spelling; see https://katex.org/docs/supported.html for supported syntax.",
});
var success = results.Count == 0 || !results.Any(r => !r.Success);
return OutputFormatter.WrapEnvelope(inner, warnings, success);
}
catch (Exception ex)
{
return RenderTopLevelError(ex, json);
}
}
/// <summary>
/// Mirrors the SDKs' <c>send(item)</c> (as distinct from <c>batch(items)</c>):
/// runs ONE batch-shaped item and returns EXACTLY what the CLI single command
/// writes to stdout — with <paramref name="json"/> the standalone command's
/// envelope (WrapEnvelopeText's `{success,data,message}` for text commands
/// like set/add/remove, WrapEnvelope's `{success,data}` for data commands like
/// get/query), without it the plain text. A failure throws instead of being
/// captured per-item, matching the standalone command's exit contract.
/// </summary>
/// <param name="itemJson">A single batch item object — the same shape as one
/// entry in <see cref="ExecuteBatch"/>'s array, or the SDKs' `send(item)` argument.</param>
/// <param name="json">Mirrors the CLI `--json` toggle: structured envelope vs plain text.</param>
public static string ExecuteSend(IDocumentHandler handler, string itemJson, bool json)
{
try
{
var item = JsonSerializer.Deserialize(itemJson, BatchJsonContext.Default.BatchItem)
?? throw new ArgumentException("send: empty item");
var inner = CommandBuilder.ExecuteBatchItem(handler, item, json);
if (!json) return inner;
// Match the standalone command's envelope by inner shape — a JSON payload
// (get/query/view/validate/…) wraps with WrapEnvelope; a plain-text
// message (set/add/remove/…) wraps with WrapEnvelopeText (which adds the
// `message` field the CLI emits). Content-based so a new command inherits
// the right envelope without a hand-kept command→envelope map.
var trimmed = inner.TrimStart();
return trimmed.StartsWith('{') || trimmed.StartsWith('[')
? OutputFormatter.WrapEnvelope(inner)
: OutputFormatter.WrapEnvelopeText(inner);
}
catch (Exception ex)
{
return RenderTopLevelError(ex, json);
}
}
/// <summary>
/// Mirror of the CLI's shared <c>WriteError(ex, json)</c> (CommandBuilder):
/// in JSON mode a failed command puts the <c>{success:false, error:{…}}</c>
/// envelope on stdout — return it byte-identically. In text mode the CLI
/// writes to stderr and leaves stdout empty; with no stderr channel in-process
/// we surface the error by propagating it (the embedder host's failure
/// signal), matching the prior throw-on-failure contract.
/// </summary>
private static string RenderTopLevelError(Exception ex, bool json)
{
// CONSISTENCY(error-wrap): same friendlier rendering the CLI applies to a
// bare XmlException from an externally-corrupted OOXML part.
var rendered = ex is System.Xml.XmlException xe
? new System.IO.InvalidDataException(
$"Malformed XML in document part: {xe.Message} " +
$"(the file appears to have a corrupted OOXML part).", xe)
: ex;
if (json) return OutputFormatter.WrapErrorEnvelope(rendered);
throw rendered;
}
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Shared RFC 5646 / BCP-47 language-tag syntax check used by every handler
/// that writes a `<*:lang>` / `<*:altLang>` attribute. Validates the shape
/// only — does not look up the tag against the IANA subtag registry. That
/// would require shipping the registry and gating new languages on each
/// release; Office itself never refuses a syntactically valid tag, so a
/// pure shape check matches recipient behavior.
///
/// Cross-handler: docx run lang.val/lang.eastAsia/lang.bidi
/// (WordHandler.Helpers.cs) and pptx run/shape lang/altLang
/// (PowerPointHandler.ShapeProperties.cs) both delegate here. Splitting
/// the two regexes would create a "valid in Word, invalid in PowerPoint"
/// drift on the same value — never a useful distinction.
/// </summary>
public static class Bcp47LanguageTag
{
public const int MaxLength = 35;
// Shape: primary subtag 2-3 letters with optional hyphenated subtags;
// 4-8 letter primary requires at least one subtag (reserved/grandfathered
// range); `x-…` private-use form. Subtags are 1-8 alphanumerics with an
// optional single `_<1-8 alnum>` legacy-collation suffix.
// R18-fuzz-3: prior `^[A-Za-z][A-Za-z0-9-]*$` form let "INVALID" and
// 1000-char garbage through; this shape rejects both.
// The `_…` suffix admits Word's legacy sort/collation language tags
// (es-ES_tradnl, ja-JP_radstr, zh-TW_pinyin, de-DE_phoneb, hu-HU_technl,
// zh-CN_stroke, ka-GE_modern, …): Word writes and reads them, so a
// dump→batch round-trip must accept them; they are still bounded (one
// underscore per subtag, 1-8 chars each, 35 overall) so garbage stays out.
private const string Subtag = @"[A-Za-z0-9]{1,8}(?:_[A-Za-z0-9]{1,8})?";
private static readonly Regex Shape = new(
@"^(?:[A-Za-z]{2,3}(?:-" + Subtag + @")*|[A-Za-z]{4,8}(?:-" + Subtag + @")+|x(?:-[A-Za-z0-9]{1,8})+)$",
RegexOptions.Compiled);
/// <summary>
/// True when the value has the BCP-47 shape (or is empty — empty means
/// "clear the slot", which callers handle separately).
/// </summary>
public static bool IsValid(string? value)
{
if (string.IsNullOrEmpty(value)) return true;
return value.Length <= MaxLength && Shape.IsMatch(value);
}
}
+45
View File
@@ -0,0 +1,45 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Precise error hints for Excel cell properties that are genuinely ambiguous
/// when carried over from PPT/Word habits.
///
/// Excel cells use a layered namespace (font.*, border.*, alignment.*, fill).
/// Most common PPT/Word flat keys — `size`, `font`, `halign`, `valign`, `wrap` —
/// are already accepted as aliases by ExcelStyleManager because they have a
/// single unambiguous meaning in cell context.
///
/// This class lists the keys that cannot be safely aliased because they mean
/// two different things. For those we refuse silent mapping and return a
/// precise hint telling the user to pick one explicitly.
/// </summary>
internal static class CellPropHints
{
private static readonly Dictionary<string, string> AmbiguousKeys = new(StringComparer.OrdinalIgnoreCase)
{
// `color` in PPT/Word run context means text color, but in Excel cells
// the user might intuitively expect background color. Force them to
// pick: `font.color` (text) or `fill` (background).
["color"] = "ambiguous in cell context — use 'font.color' for text color or 'fill' for background color",
// R17 bt-3: `path=` looks plausible (path-like keys exist for picture/ole)
// but cell uses `ref=` (or `address=`) for the target address. Silently
// dropping `path` writes the value to the wrong cell — fail loudly.
["path"] = "not a cell property — use 'ref' (or 'address') for the cell address, e.g. --prop ref=D5",
};
/// <summary>
/// If the given key is a known ambiguous cell prop, returns a human-readable
/// hint telling the user to pick an unambiguous alternative. Returns null
/// otherwise.
/// </summary>
public static string? TryGetHint(string key)
{
if (!AmbiguousKeys.TryGetValue(key, out var hint))
return null;
return $"{key} ({hint})";
}
}
@@ -0,0 +1,710 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using Drawing = DocumentFormat.OpenXml.Drawing;
using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
namespace OfficeCli.Core;
/// <summary>
/// Set-side (mutate-in-place) implementation for cx:chart extended chart
/// types. Covers the same vocabulary as the Add path in ChartExBuilder.cs
/// so charts created via Add can be fully re-styled via Set.
///
/// The shape of each case mirrors ChartHelper.Setter.cs for regular cChart:
/// remove the existing styled element, rebuild it via a shared helper (or
/// mutate in place), and save. All tree mutations respect the CT_Axis /
/// CT_Chart schema order.
/// </summary>
internal static partial class ChartExBuilder
{
/// <summary>
/// Mutate an existing <see cref="ExtendedChartPart"/> to apply the given
/// properties. Returns the list of keys that weren't recognized (caller
/// surfaces these to the user). Unknown keys are never an error — same
/// convention as ChartHelper.SetChartProperties.
/// </summary>
internal static List<string> SetChartProperties(
ExtendedChartPart chartPart, Dictionary<string, string> properties)
{
var unsupported = new List<string>();
var chartSpace = chartPart.ChartSpace;
var chart = chartSpace?.GetFirstChild<CX.Chart>();
if (chart == null) { unsupported.AddRange(properties.Keys); return unsupported; }
var plotArea = chart.GetFirstChild<CX.PlotArea>();
var plotAreaRegion = plotArea?.GetFirstChild<CX.PlotAreaRegion>();
var allSeries = plotAreaRegion?.Elements<CX.Series>().ToList() ?? new List<CX.Series>();
var allAxes = plotArea?.Elements<CX.Axis>().ToList() ?? new List<CX.Axis>();
var catAxis = allAxes.FirstOrDefault(); // Id=0 — category axis (histogram/boxWhisker)
var valAxis = allAxes.ElementAtOrDefault(1); // Id=1 — value axis
// Process structural properties (title text, axis title creation) before
// styling properties (title.color, axisTitle.color) so the target element
// always exists by the time the styling case runs. Same trick as the
// regular cChart setter.
static int PropOrder(string k)
{
var lower = k.ToLowerInvariant();
if (lower is "title" or "xaxistitle" or "yaxistitle" or "legend") return 0;
return 1;
}
foreach (var (key, value) in properties.OrderBy(kv => PropOrder(kv.Key)))
{
var handled = HandleSetKey(chart, plotArea, allSeries, allAxes, catAxis, valAxis,
key, value, properties);
if (!handled) unsupported.Add(key);
}
chartPart.ChartSpace?.Save();
return unsupported;
}
// The per-key dispatch lives in its own method so the surrounding loop
// stays readable. Returns true if the key was recognized (regardless of
// whether anything could actually be mutated — e.g. styling a non-existent
// title is a silent no-op, not an unsupported-key report, matching regular
// cChart semantics).
private static bool HandleSetKey(
CX.Chart chart,
CX.PlotArea? plotArea,
List<CX.Series> allSeries,
List<CX.Axis> allAxes,
CX.Axis? catAxis,
CX.Axis? valAxis,
string key,
string value,
Dictionary<string, string> allProperties)
{
switch (key.ToLowerInvariant())
{
// ==================== Chart title ====================
case "title":
{
chart.RemoveAllChildren<CX.ChartTitle>();
if (!string.IsNullOrEmpty(value)
&& !value.Equals("none", StringComparison.OrdinalIgnoreCase)
&& !value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
// cx:title must be the first child of cx:chart per schema.
chart.PrependChild(BuildChartTitle(value, allProperties));
}
return true;
}
case "title.color" or "titlecolor":
case "title.size" or "titlesize":
case "title.font" or "titlefont":
case "title.bold" or "titlebold":
{
var ctitle = chart.GetFirstChild<CX.ChartTitle>();
if (ctitle == null) return true; // silent no-op
foreach (var run in ctitle.Descendants<Drawing.Run>())
{
var rPr = run.RunProperties
?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" });
ChartHelper.ApplyRunStyleProperties(rPr, allProperties, keyPrefix: "title");
}
return true;
}
case "title.shadow" or "titleshadow":
{
// Apply an a:outerShdw effect to the title run's rPr. Same
// vocabulary as regular cChart (ChartHelper.Setter.cs:63):
// "COLOR-BLUR-ANGLE-DIST-OPACITY" or "none" to clear.
var ctitle = chart.GetFirstChild<CX.ChartTitle>();
if (ctitle == null) return true;
foreach (var run in ctitle.Descendants<Drawing.Run>())
{
var rPr = run.RunProperties
?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" });
ApplyRunEffectShadow(rPr, value);
}
return true;
}
// ==================== Legend ====================
case "legend":
{
chart.RemoveAllChildren<CX.Legend>();
if (!string.IsNullOrEmpty(value)
&& !value.Equals("none", StringComparison.OrdinalIgnoreCase)
&& !value.Equals("false", StringComparison.OrdinalIgnoreCase)
&& !value.Equals("off", StringComparison.OrdinalIgnoreCase))
{
// Legend goes after plotArea per cx:chart schema.
chart.AppendChild(BuildLegend(value, allProperties));
}
return true;
}
case "legend.overlay" or "legendoverlay":
{
var legend = chart.GetFirstChild<CX.Legend>();
if (legend == null) return true;
legend.Overlay = ParseHelpers.IsTruthy(value);
return true;
}
case "legendfont" or "legend.font":
{
// Compound form "size:color:fontname" styles the legend text.
// Mirrors ChartHelper.Setter.cs:118 "legendfont" for regular
// cChart. Wraps an a:defRPr in cx:txPr on the legend.
var legend = chart.GetFirstChild<CX.Legend>();
if (legend == null) return true;
legend.RemoveAllChildren<CX.TxPrTextBody>();
if (!string.IsNullOrEmpty(value)
&& !value.Equals("none", StringComparison.OrdinalIgnoreCase))
{
var txPr = BuildAxisTickLabelStyle(value);
if (txPr != null) legend.AppendChild(txPr);
}
return true;
}
// ==================== Axis titles (text) ====================
case "xaxistitle":
{
if (catAxis == null) return true;
catAxis.RemoveAllChildren<CX.AxisTitle>();
if (!string.IsNullOrEmpty(value)
&& !value.Equals("none", StringComparison.OrdinalIgnoreCase))
{
InsertAxisTitle(catAxis, BuildAxisTitle(value, allProperties));
}
return true;
}
case "yaxistitle":
{
if (valAxis == null) return true;
valAxis.RemoveAllChildren<CX.AxisTitle>();
if (!string.IsNullOrEmpty(value)
&& !value.Equals("none", StringComparison.OrdinalIgnoreCase))
{
InsertAxisTitle(valAxis, BuildAxisTitle(value, allProperties));
}
return true;
}
case "axistitle.color" or "axistitlecolor":
case "axistitle.size" or "axistitlesize":
case "axistitle.font" or "axistitlefont":
case "axistitle.bold" or "axistitlebold":
{
foreach (var axis in allAxes)
{
var axisTitle = axis.GetFirstChild<CX.AxisTitle>();
if (axisTitle == null) continue;
foreach (var run in axisTitle.Descendants<Drawing.Run>())
{
var rPr = run.RunProperties
?? (run.RunProperties = new Drawing.RunProperties { Language = "en-US" });
ChartHelper.ApplyRunStyleProperties(rPr, allProperties, keyPrefix: "axisTitle");
}
}
return true;
}
// ==================== Tick-label font (axis-level cx:txPr) ====================
case "axisfont" or "axis.font":
{
foreach (var axis in allAxes)
{
// cx:txPr must remain the last axis child (per CT_Axis schema:
// ... → tickLabels → numFmt → spPr → txPr → extLst).
axis.RemoveAllChildren<CX.TxPrTextBody>();
var txPr = BuildAxisTickLabelStyle(value);
if (txPr != null) axis.AppendChild(txPr);
}
return true;
}
// ==================== Gridlines ====================
case "gridlines":
{
if (valAxis == null) return true;
valAxis.RemoveAllChildren<CX.MajorGridlinesGridlines>();
if (ParseHelpers.IsTruthy(value))
InsertGridlinesInAxisOrder(valAxis, new CX.MajorGridlinesGridlines());
return true;
}
case "xgridlines":
{
if (catAxis == null) return true;
catAxis.RemoveAllChildren<CX.MajorGridlinesGridlines>();
if (ParseHelpers.IsTruthy(value))
InsertGridlinesInAxisOrder(catAxis, new CX.MajorGridlinesGridlines());
return true;
}
case "gridlinecolor" or "gridline.color":
{
var gl = valAxis?.GetFirstChild<CX.MajorGridlinesGridlines>();
if (gl != null) gl.ShapeProperties = BuildGridlineShapeProperties(value);
return true;
}
case "xgridlinecolor" or "xgridline.color":
{
var gl = catAxis?.GetFirstChild<CX.MajorGridlinesGridlines>();
if (gl != null) gl.ShapeProperties = BuildGridlineShapeProperties(value);
return true;
}
// ==================== Value-axis scaling (axismin/max/majorunit) ====================
// CONSISTENCY(chart-axis-scaling): same prop names as regular cChart
// (ChartHelper.Setter.cs:357). CX.ValueAxisScaling stores Min/Max/
// MajorUnit/MinorUnit as StringValue attributes, not typed doubles,
// but we still parse + re-format as invariant double for
// consistency with cChart behavior (reject NaN/Infinity).
case "axismin" or "min":
{
var valScaling = valAxis?.GetFirstChild<CX.ValueAxisScaling>();
if (valScaling == null) return true;
valScaling.Min = ParseHelpers.SafeParseDouble(value, "axismin")
.ToString("G", CultureInfo.InvariantCulture);
return true;
}
case "axismax" or "max":
{
var valScaling = valAxis?.GetFirstChild<CX.ValueAxisScaling>();
if (valScaling == null) return true;
valScaling.Max = ParseHelpers.SafeParseDouble(value, "axismax")
.ToString("G", CultureInfo.InvariantCulture);
return true;
}
case "majorunit":
{
var valScaling = valAxis?.GetFirstChild<CX.ValueAxisScaling>();
if (valScaling == null) return true;
valScaling.MajorUnit = ParseHelpers.SafeParseDouble(value, "majorunit")
.ToString("G", CultureInfo.InvariantCulture);
return true;
}
case "minorunit":
{
var valScaling = valAxis?.GetFirstChild<CX.ValueAxisScaling>();
if (valScaling == null) return true;
valScaling.MinorUnit = ParseHelpers.SafeParseDouble(value, "minorunit")
.ToString("G", CultureInfo.InvariantCulture);
return true;
}
// ==================== Axis visibility (hidden flag) ====================
// CONSISTENCY(chart-axis-visibility): same prop names as regular
// cChart (ChartHelper.Setter.cs:795). CX uses a simple @hidden
// attribute on cx:axis, unlike cChart's c:delete child element.
case "axisvisible" or "axis.visible" or "axis.delete":
{
var hide = key.Contains("delete")
? ParseHelpers.IsTruthy(value)
: !ParseHelpers.IsTruthy(value);
foreach (var axis in allAxes) axis.Hidden = hide;
return true;
}
case "cataxisvisible" or "cataxis.visible":
{
if (catAxis != null) catAxis.Hidden = !ParseHelpers.IsTruthy(value);
return true;
}
case "valaxisvisible" or "valaxis.visible":
{
if (valAxis != null) valAxis.Hidden = !ParseHelpers.IsTruthy(value);
return true;
}
// ==================== Axis line styling ====================
// CONSISTENCY(chart-axis-line): "color" | "color:width" | "color:width:dash"
// | "none". Same vocabulary as regular cChart (ChartHelper.Setter.cs:1471),
// reuses ChartHelper.BuildOutlineElement for parsing.
case "axisline" or "axis.line":
{
foreach (var axis in allAxes) ApplyCxAxisLine(axis, value);
return true;
}
case "cataxisline" or "cataxis.line":
{
if (catAxis != null) ApplyCxAxisLine(catAxis, value);
return true;
}
case "valaxisline" or "valaxis.line":
{
if (valAxis != null) ApplyCxAxisLine(valAxis, value);
return true;
}
// ==================== Tick labels (on/off, both axes) ====================
case "ticklabels":
{
var enable = ParseHelpers.IsTruthy(value);
foreach (var axis in allAxes)
{
axis.RemoveAllChildren<CX.TickLabels>();
if (enable) InsertTickLabelsInAxisOrder(axis, new CX.TickLabels());
}
return true;
}
// ==================== Data labels (series-level) ====================
case "datalabels" or "labels":
{
var enable = ParseHelpers.IsTruthy(value);
foreach (var series in allSeries)
{
series.RemoveAllChildren<CX.DataLabels>();
if (!enable) continue;
// CONSISTENCY(chartex-sidecars): omit `pos` — chartEx
// labels do not carry it, and PowerPoint flags the file
// as needing repair when present.
var dl = new CX.DataLabels();
dl.AppendChild(new CX.DataLabelVisibilities
{
Value = true, SeriesName = false, CategoryName = false,
});
// dataLabels goes before cx:dataId per cx:series schema.
var dataId = series.GetFirstChild<CX.DataId>();
if (dataId != null) series.InsertBefore(dl, dataId);
else series.AppendChild(dl);
}
return true;
}
case "datalabels.numfmt" or "labelnumfmt" or "datalabels.format" or "labelformat":
{
// CONSISTENCY(chart-datalabel-numfmt): same prop names as
// regular cChart (ChartHelper.Setter.cs:1181). Applies a
// cx:numFmt element to every series' cx:dataLabels. Silent
// no-op if a series has no dataLabels block (use `dataLabels=true`
// to enable them first, same as regular cChart semantics).
foreach (var series in allSeries)
{
var dl = series.GetFirstChild<CX.DataLabels>();
if (dl == null) continue;
dl.NumberFormat = new CX.NumberFormat
{
FormatCode = value,
SourceLinked = false,
};
}
return true;
}
// CONSISTENCY(chart-datalabels-toggle): same prop names as regular
// cChart (ChartHelper.Setter.cs:2208). cx charts express label
// components via CX.DataLabelVisibilities attributes (Value /
// CategoryName). Silent no-op if a series has no dataLabels block
// (use `datalabels=true` first), same as the datalabels.numfmt case.
case "datalabels.showvalue" or "datalabels.showval"
or "showvalue" or "showval":
{
var show = ParseHelpers.IsTruthy(value);
foreach (var series in allSeries)
{
var vis = series.GetFirstChild<CX.DataLabels>()
?.GetFirstChild<CX.DataLabelVisibilities>();
if (vis != null) vis.Value = show;
}
return true;
}
case "datalabels.showcatname" or "datalabels.showcategoryname" or "datalabels.showcategory"
or "showcatname" or "showcategoryname" or "showcategory":
{
var show = ParseHelpers.IsTruthy(value);
foreach (var series in allSeries)
{
var vis = series.GetFirstChild<CX.DataLabels>()
?.GetFirstChild<CX.DataLabelVisibilities>();
if (vis != null) vis.CategoryName = show;
}
return true;
}
// ==================== Series fill / multi-series colors ====================
case "fill":
{
foreach (var series in allSeries)
ReplaceSeriesFill(series, value);
return true;
}
case "colors":
{
var colorList = value.Split(',').Select(c => c.Trim()).ToArray();
for (int i = 0; i < Math.Min(allSeries.Count, colorList.Length); i++)
ReplaceSeriesFill(allSeries[i], colorList[i]);
return true;
}
// ==================== Series effects (shadow) ====================
// CONSISTENCY(chart-series-shadow): same vocabulary as regular cChart
// (ChartHelper.Setter.cs:642 / SetterHelpers.cs:374). Format
// "COLOR-BLUR-ANGLE-DIST-OPACITY" or "none" to clear. Applied to
// every series by attaching an a:effectLst inside the existing
// cx:spPr (or creating one if the series has no fill yet).
case "series.shadow" or "seriesshadow":
{
foreach (var series in allSeries)
ApplyCxSeriesShadow(series, value);
return true;
}
// ==================== Histogram binning ====================
case "bincount":
{
SetHistogramBinSpec(allSeries, kind: "binCount", rawValue: value);
return true;
}
case "binsize":
{
SetHistogramBinSpec(allSeries, kind: "binSize", rawValue: value);
return true;
}
case "intervalclosed":
{
foreach (var series in allSeries)
{
var binning = series.Descendants<CX.Binning>().FirstOrDefault();
if (binning == null) continue;
binning.IntervalClosed = value.ToLowerInvariant() == "l"
? CX.IntervalClosedSide.L
: CX.IntervalClosedSide.R;
}
return true;
}
case "underflowbin":
{
foreach (var series in allSeries)
{
var binning = series.Descendants<CX.Binning>().FirstOrDefault();
if (binning != null)
binning.Underflow = string.IsNullOrEmpty(value) ? null : value;
}
return true;
}
case "overflowbin":
{
foreach (var series in allSeries)
{
var binning = series.Descendants<CX.Binning>().FirstOrDefault();
if (binning != null)
binning.Overflow = string.IsNullOrEmpty(value) ? null : value;
}
return true;
}
case "gapwidth":
{
var catScaling = catAxis?.GetFirstChild<CX.CategoryAxisScaling>();
if (catScaling != null) catScaling.GapWidth = value;
return true;
}
// ==================== Other extended-type layoutPr ====================
case "parentlabellayout": // treemap
{
foreach (var series in allSeries)
{
var parentLabel = series.Descendants<CX.ParentLabelLayout>().FirstOrDefault();
if (parentLabel == null) continue;
parentLabel.ParentLabelLayoutVal = value.ToLowerInvariant() switch
{
"none" => CX.ParentLabelLayoutVal.None,
"banner" => CX.ParentLabelLayoutVal.Banner,
_ => CX.ParentLabelLayoutVal.Overlapping,
};
}
return true;
}
case "quartilemethod": // boxwhisker
{
foreach (var series in allSeries)
{
var stats = series.Descendants<CX.Statistics>().FirstOrDefault();
if (stats == null) continue;
stats.QuartileMethod = value.ToLowerInvariant() == "inclusive"
? CX.QuartileMethod.Inclusive
: CX.QuartileMethod.Exclusive;
}
return true;
}
// ==================== Plot area / chart area fill + border ====================
// CONSISTENCY(chart-area-fill): same prop names as regular cChart
// (ChartHelper.Setter.cs:476,491,1220,1232). Both PlotArea and
// ChartSpace accept a cx:spPr child; we attach a solidFill for
// the background and an a:ln outline for the border.
case "plotareafill" or "plotfill":
{
if (plotArea == null) return true;
ApplyCxAreaFill(plotArea, value);
return true;
}
case "plotarea.border" or "plotborder":
{
if (plotArea == null) return true;
ApplyCxAreaBorder(plotArea, value);
return true;
}
case "chartareafill" or "chartfill":
{
var chartSpace = chart.Parent as CX.ChartSpace;
if (chartSpace == null) return true;
ApplyCxAreaFill(chartSpace, value);
return true;
}
case "chartarea.border" or "chartborder":
{
var chartSpace = chart.Parent as CX.ChartSpace;
if (chartSpace == null) return true;
ApplyCxAreaBorder(chartSpace, value);
return true;
}
}
return false;
}
// ==================== Schema-aware insertion helpers ====================
/// <summary>
/// Insert a <see cref="CX.AxisTitle"/> into an axis, respecting the
/// CT_Axis sequence: catScaling/valScaling → title → units → gridlines → ...
/// </summary>
private static void InsertAxisTitle(CX.Axis axis, CX.AxisTitle title)
{
// Title goes immediately after catScaling/valScaling.
var scaling = axis.GetFirstChild<CX.CategoryAxisScaling>() as OpenXmlElement
?? axis.GetFirstChild<CX.ValueAxisScaling>();
if (scaling != null) scaling.InsertAfterSelf(title);
else axis.PrependChild(title);
}
/// <summary>
/// Insert majorGridlines after title (or scaling) but before tickLabels /
/// spPr / txPr, matching the CT_Axis schema sequence.
/// </summary>
private static void InsertGridlinesInAxisOrder(CX.Axis axis, CX.MajorGridlinesGridlines gl)
{
var insertAfter = (OpenXmlElement?)axis.GetFirstChild<CX.AxisTitle>()
?? (OpenXmlElement?)axis.GetFirstChild<CX.CategoryAxisScaling>()
?? axis.GetFirstChild<CX.ValueAxisScaling>();
if (insertAfter != null) insertAfter.InsertAfterSelf(gl);
else axis.PrependChild(gl);
}
/// <summary>
/// Insert tickLabels after gridlines (or earlier children) but before
/// axis-level spPr / txPr.
/// </summary>
private static void InsertTickLabelsInAxisOrder(CX.Axis axis, CX.TickLabels tickLabels)
{
// cx:txPr is what our Set path appends to the axis for tick-label
// styling; tickLabels must come BEFORE any existing txPr.
var existingTxPr = axis.GetFirstChild<CX.TxPrTextBody>();
if (existingTxPr != null)
{
axis.InsertBefore(tickLabels, existingTxPr);
return;
}
var insertAfter = (OpenXmlElement?)axis.GetFirstChild<CX.MajorGridlinesGridlines>()
?? (OpenXmlElement?)axis.GetFirstChild<CX.AxisTitle>()
?? (OpenXmlElement?)axis.GetFirstChild<CX.CategoryAxisScaling>()
?? axis.GetFirstChild<CX.ValueAxisScaling>();
if (insertAfter != null) insertAfter.InsertAfterSelf(tickLabels);
else axis.AppendChild(tickLabels);
}
// ==================== Series-level helpers ====================
/// <summary>
/// Replace the series fill color (single solid fill). Used by both
/// `fill` and `colors` cases.
/// </summary>
private static void ReplaceSeriesFill(CX.Series series, string color)
{
if (string.IsNullOrEmpty(color)) return;
series.RemoveAllChildren<CX.ShapeProperties>();
var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(color);
var spPr = new CX.ShapeProperties(
new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = rgb }));
// spPr goes right after cx:tx per cx:series schema sequence.
var tx = series.GetFirstChild<CX.Text>();
if (tx != null) tx.InsertAfterSelf(spPr);
else series.PrependChild(spPr);
}
/// <summary>
/// Replace a histogram's <c>cx:binCount</c> / <c>cx:binSize</c> with the
/// given value. Binning is XOR — setting one removes the other. Uses the
/// same OpenXmlUnknownElement workaround as the Add path (SDK's typed
/// binCount is a leaf-text element but Excel wants a <c>val</c> attribute).
/// </summary>
private static void SetHistogramBinSpec(
IReadOnlyList<CX.Series> allSeries, string kind, string rawValue)
{
const string cxNs = "http://schemas.microsoft.com/office/drawing/2014/chartex";
foreach (var series in allSeries)
{
var lp = series.GetFirstChild<CX.SeriesLayoutProperties>();
if (lp == null) continue;
var binning = lp.GetFirstChild<CX.Binning>();
if (binning == null) continue;
// Remove any existing binCount / binSize (XOR with the new one).
foreach (var existing in binning.ChildElements.ToList())
if (existing.LocalName is "binCount" or "binSize") existing.Remove();
if (string.IsNullOrEmpty(rawValue)) continue; // bare "bincount=" clears
if (kind == "binCount" && uint.TryParse(rawValue, out var binCount))
{
var el = new OpenXmlUnknownElement("cx", "binCount", cxNs);
el.SetAttribute(new OpenXmlAttribute("val", "", binCount.ToString()));
binning.AppendChild(el);
}
else if (kind == "binSize"
&& double.TryParse(rawValue, NumberStyles.Float, CultureInfo.InvariantCulture,
out var binSize))
{
var el = new OpenXmlUnknownElement("cx", "binSize", cxNs);
el.SetAttribute(new OpenXmlAttribute("val", "",
binSize.ToString("G", CultureInfo.InvariantCulture)));
binning.AppendChild(el);
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,154 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Spreadsheet;
namespace OfficeCli.Core;
/// <summary>
/// Resource provider for the three chartEx sidecar parts that PowerPoint
/// and Word require alongside an ExtendedChartPart:
///
/// 1. EmbeddedPackagePart (.xlsx) — referenced by &lt;cx:externalData r:id="rId1"/&gt;
/// 2. ChartStylePart (style1.xml, cs:chartStyle id="419")
/// 3. ChartColorStylePart (colors1.xml, cs:colorStyle method="cycle" id="10")
///
/// Without these sidecars Excel/PowerPoint silently "repairs" the file by
/// dropping the chart (or the entire drawing it lives in). The chartStyle
/// and colorStyle XML are layout-/data-independent and reused verbatim from
/// a canonical funnel reference; the embedded xlsx is built programmatically
/// per-chart so its Sheet1!$A:$Z cells match the cx:f formulas emitted by
/// ChartExBuilder.
///
/// CONSISTENCY(chartex-sidecars): Excel's path uses ChartExStyleBuilder for
/// a per-type style; PPT/Word use the canonical funnel template here. Both
/// produce schema-valid sidecars that satisfy Office's "must have these
/// rels" check.
/// </summary>
internal static class ChartExResources
{
/// <summary>
/// Build a minimal embedded .xlsx as a byte stream. Sheet1 contains:
/// row 1: ["", seriesName1, seriesName2, ...]
/// row 2..N+1: [category, value1, value2, ...]
/// Categories may be null (histogram) — in that case row 1's A column
/// is still empty and only numeric data fills column B onward.
/// </summary>
internal static byte[] BuildMinimalEmbeddedXlsx(
string[]? categories,
List<(string name, double[] values)> seriesData)
{
using var ms = new MemoryStream();
using (var doc = SpreadsheetDocument.Create(ms, DocumentFormat.OpenXml.SpreadsheetDocumentType.Workbook))
{
var wbPart = doc.AddWorkbookPart();
wbPart.Workbook = new Workbook();
var wsPart = wbPart.AddNewPart<WorksheetPart>();
var sheetData = new SheetData();
int rowCount = categories?.Length ?? (seriesData.Count > 0 ? seriesData[0].values.Length : 0);
// Row 1 — headers: A1 is empty, B1..K1 are series names.
var headerRow = new Row { RowIndex = 1U };
headerRow.Append(new Cell
{
CellReference = "A1",
DataType = CellValues.String,
CellValue = new CellValue(""),
});
for (int s = 0; s < seriesData.Count; s++)
{
headerRow.Append(new Cell
{
CellReference = $"{ColumnLetter(s + 2)}1",
DataType = CellValues.String,
CellValue = new CellValue(seriesData[s].name ?? $"Series{s + 1}"),
});
}
sheetData.AppendChild(headerRow);
// Data rows
for (int r = 0; r < rowCount; r++)
{
var row = new Row { RowIndex = (uint)(r + 2) };
if (categories != null && r < categories.Length)
{
row.Append(new Cell
{
CellReference = $"A{r + 2}",
DataType = CellValues.String,
CellValue = new CellValue(categories[r] ?? string.Empty),
});
}
for (int s = 0; s < seriesData.Count; s++)
{
var values = seriesData[s].values;
if (r >= values.Length) continue;
row.Append(new Cell
{
CellReference = $"{ColumnLetter(s + 2)}{r + 2}",
DataType = CellValues.Number,
CellValue = new CellValue(values[r].ToString("G", CultureInfo.InvariantCulture)),
});
}
sheetData.AppendChild(row);
}
wsPart.Worksheet = new Worksheet(sheetData);
var sheets = wbPart.Workbook.AppendChild(new Sheets());
sheets.Append(new Sheet
{
Id = wbPart.GetIdOfPart(wsPart),
SheetId = 1U,
Name = "Sheet1",
});
wbPart.Workbook.Save();
}
return ms.ToArray();
}
/// <summary>
/// Return the canonical chartStyle XML (cs:chartStyle id="419") used by
/// PowerPoint/Word ExtendedChartPart sidecars. Loaded once from the
/// embedded resource Resources/chartex-style.xml.
/// </summary>
internal static Stream OpenChartStyleXml() => OpenResource("chartex-style.xml");
/// <summary>
/// Return the canonical colorStyle XML (cs:colorStyle method="cycle"
/// id="10"). Same content as Excel's chart palette.
/// </summary>
internal static Stream OpenChartColorStyleXml() => OpenResource("chartex-colors.xml");
private static Stream OpenResource(string fileName)
{
var assembly = typeof(ChartExResources).Assembly;
var name = $"OfficeCli.Resources.{fileName}";
return assembly.GetManifestResourceStream(name)
?? throw new InvalidOperationException(
$"Embedded resource not found: {name}. Ensure it is declared in officecli.csproj.");
}
/// <summary>
/// Convert a 1-based column index to its Excel column letter (1=A, 2=B,
/// 27=AA, ...). Used for both embedded-xlsx cell refs and cx:f formulas.
/// </summary>
internal static string ColumnLetter(int index1Based)
{
if (index1Based <= 0) throw new ArgumentOutOfRangeException(nameof(index1Based));
var sb = new System.Text.StringBuilder();
int n = index1Based;
while (n > 0)
{
int rem = (n - 1) % 26;
sb.Insert(0, (char)('A' + rem));
n = (n - 1) / 26;
}
return sb.ToString();
}
}
@@ -0,0 +1,381 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text;
using System.Text.Json;
namespace OfficeCli.Core;
/// <summary>
/// Section-based assembler for the cx chartStyle sidecar (an OOXML
/// chartEx auxiliary part defined by ECMA-376 / ISO/IEC 29500). Iterates
/// the canonical chartStyle section tags in schema-required order and
/// emits, for each section, either a curated fragment looked up by the
/// caller's (chartType, variant) key or a minimal schema-compliant
/// fallback provided by <see cref="MinimalScaffold"/>.
///
/// The result is a single byte stream suitable for feeding directly
/// into <c>ChartStylePart.FeedData</c>.
/// </summary>
internal static class ChartExStyleBuilder
{
/// <summary>
/// Canonical chartStyle section order. Must match the CT_ChartStyle
/// schema sequence — Excel silently repairs (drops) the whole chart
/// if a section is missing, reordered, or unknown.
/// </summary>
internal static readonly string[] Sections = new[]
{
"axisTitle",
"categoryAxis",
"chartArea",
"dataLabel",
"dataLabelCallout",
"dataPoint",
"dataPoint3D",
"dataPointLine",
"dataPointMarker",
"dataPointMarkerLayout",
"dataPointWireframe",
"dataTable",
"downBar",
"dropLine",
"errorBar",
"floor",
"gridlineMajor",
"gridlineMinor",
"hiLoLine",
"leaderLine",
"legend",
"plotArea",
"plotArea3D",
"seriesAxis",
"seriesLine",
"title",
"trendline",
"trendlineLabel",
"upBar",
"valueAxis",
"wall",
};
private const string CsNs = "http://schemas.microsoft.com/office/drawing/2012/chartStyle";
private const string ANs = "http://schemas.openxmlformats.org/drawingml/2006/main";
/// <summary>
/// Build a cx chartStyle.xml stream for the given chart type and
/// optional style variant. Caller feeds the stream into
/// <c>ChartStylePart.FeedData</c>.
/// </summary>
/// <param name="chartType">
/// The cx chart type name (case-insensitive, whitespace/dash/underscore
/// tolerated via <see cref="NormalizeTypeForLookup"/>). Used as part
/// of the section lookup key.
/// </param>
/// <param name="variant">
/// Optional style variant name. Defaults to <c>"default"</c>. Also
/// accepts <c>"style1"</c>..<c>"style10"</c> or bare integers
/// <c>"1"</c>..<c>"10"</c>.
/// </param>
internal static Stream BuildChartStyleXml(
string chartType, string variant = "default")
{
var normalizedType = NormalizeTypeForLookup(chartType);
var normalizedVariant = NormalizeVariantForLookup(variant);
var entry = GalleryIndex.TryGet(normalizedType, normalizedVariant);
var styleId = entry?.StyleId ?? 410;
var sb = new StringBuilder(4096);
sb.Append("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>");
sb.Append(
$"<cs:chartStyle xmlns:cs=\"{CsNs}\" xmlns:a=\"{ANs}\" id=\"{styleId}\">");
foreach (var section in Sections)
{
string? fragment = null;
if (entry != null
&& entry.Fragments.TryGetValue(section, out var fragId))
{
fragment = FragmentStore.TryLoad(fragId);
}
// Any missing section falls through to the minimal
// schema-compliant scaffold below.
fragment ??= MinimalScaffold.For(section);
sb.Append(fragment);
}
sb.Append("</cs:chartStyle>");
return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString()));
}
/// <summary>
/// Normalize a chart type name to the lookup key used by the
/// internal style index. Matches <c>ChartExBuilder.IsExtendedChartType</c>
/// so "Box Whisker" / "box-whisker" / "BOXWHISKER" / "box_whisker"
/// all resolve to the same entry.
/// </summary>
internal static string NormalizeTypeForLookup(string chartType)
{
return chartType.ToLowerInvariant()
.Replace(" ", "")
.Replace("_", "")
.Replace("-", "");
}
/// <summary>
/// Normalize a variant name to the lookup key used by the internal
/// style index. Accepts <c>default</c>, <c>style{N}</c>, bare
/// integers (<c>"3"</c> → <c>"style3"</c>), and any case.
/// </summary>
internal static string NormalizeVariantForLookup(string variant)
{
if (string.IsNullOrWhiteSpace(variant)) return "default";
var v = variant.Trim().ToLowerInvariant();
if (v == "default" || v == "0") return "default";
if (int.TryParse(v, out var n) && n >= 1 && n <= 10) return $"style{n}";
return v;
}
}
/// <summary>
/// Minimal schema-compliant default fragments for cx chartStyle sections.
/// Every fragment is a self-contained <c>&lt;cs:section&gt;</c> element
/// with zero chart-type dependencies — safe to emit for any cx chart.
/// Each child of <c>cs:styleEntry</c> is <c>minOccurs=0</c> per
/// <c>CT_StyleEntry</c>, so the generic 4-ref form is the smallest
/// schema-valid content Excel accepts.
/// </summary>
internal static class MinimalScaffold
{
/// <summary>
/// Return the minimal default fragment for a given chartStyle section
/// name. Specific sections need enriched content to keep the chart
/// visually coherent; the rest get the generic 4-ref scaffold.
/// </summary>
internal static string For(string section) => section switch
{
// chartArea needs a visible background + outline for the chart
// rectangle to render at all.
"chartArea" =>
"<cs:chartArea mods=\"allowNoFillOverride allowNoLineOverride\">" +
"<cs:lnRef idx=\"0\"/>" +
"<cs:fillRef idx=\"0\"/>" +
"<cs:effectRef idx=\"0\"/>" +
"<cs:fontRef idx=\"minor\">" +
"<a:schemeClr val=\"tx1\"/>" +
"</cs:fontRef>" +
"<cs:spPr>" +
"<a:solidFill><a:schemeClr val=\"bg1\"/></a:solidFill>" +
"<a:ln w=\"9525\" cap=\"flat\" cmpd=\"sng\" algn=\"ctr\">" +
"<a:solidFill>" +
"<a:schemeClr val=\"tx1\">" +
"<a:lumMod val=\"15000\"/>" +
"<a:lumOff val=\"85000\"/>" +
"</a:schemeClr>" +
"</a:solidFill>" +
"<a:round/>" +
"</a:ln>" +
"</cs:spPr>" +
"</cs:chartArea>",
// dataPoint uses the phClr placeholder fill so the accent color
// from the accompanying chartColorStyle sidecar flows through.
"dataPoint" =>
"<cs:dataPoint>" +
"<cs:lnRef idx=\"0\"/>" +
"<cs:fillRef idx=\"0\"><cs:styleClr val=\"auto\"/></cs:fillRef>" +
"<cs:effectRef idx=\"0\"/>" +
"<cs:fontRef idx=\"minor\">" +
"<a:schemeClr val=\"tx1\"/>" +
"</cs:fontRef>" +
"<cs:spPr>" +
"<a:solidFill><a:schemeClr val=\"phClr\"/></a:solidFill>" +
"</cs:spPr>" +
"</cs:dataPoint>",
// dataPointMarkerLayout is a self-closing element with
// symbol/size attributes per CT_MarkerLayoutProperties — unlike
// every other section it's not a CT_StyleEntry composite.
"dataPointMarkerLayout" =>
"<cs:dataPointMarkerLayout symbol=\"circle\" size=\"5\"/>",
// plotArea / plotArea3D carry the `mods` attribute so Excel
// honors user fill/line overrides emitted into chart.xml via
// the plotareafill / plotarea.border knobs.
"plotArea" =>
"<cs:plotArea mods=\"allowNoFillOverride allowNoLineOverride\">" +
"<cs:lnRef idx=\"0\"/>" +
"<cs:fillRef idx=\"0\"/>" +
"<cs:effectRef idx=\"0\"/>" +
"<cs:fontRef idx=\"minor\"/>" +
"</cs:plotArea>",
"plotArea3D" =>
"<cs:plotArea3D mods=\"allowNoFillOverride allowNoLineOverride\">" +
"<cs:lnRef idx=\"0\"/>" +
"<cs:fillRef idx=\"0\"/>" +
"<cs:effectRef idx=\"0\"/>" +
"<cs:fontRef idx=\"minor\"/>" +
"</cs:plotArea3D>",
// Generic 4-ref scaffold — the smallest schema-valid form per
// CT_StyleEntry (every child is minOccurs=0).
_ =>
$"<cs:{section}>" +
"<cs:lnRef idx=\"0\"/>" +
"<cs:fillRef idx=\"0\"/>" +
"<cs:effectRef idx=\"0\"/>" +
"<cs:fontRef idx=\"minor\"/>" +
$"</cs:{section}>"
};
}
/// <summary>
/// In-memory lookup table mapping <c>(chartType, variant)</c> to a set
/// of per-section fragment IDs consumed by <see cref="ChartExStyleBuilder"/>.
/// Backed by an optional embedded resource; if the resource isn't
/// present, <see cref="TryGet"/> always returns null and the builder
/// emits <see cref="MinimalScaffold"/> everywhere.
///
/// Lazy-loaded on first access, cached for process lifetime, thread-safe
/// via double-checked lock.
/// </summary>
internal static class GalleryIndex
{
private const string IndexResourceName =
"OfficeCli.Resources.cx-gallery.index.json";
private static Dictionary<string, GalleryEntry>? _cache;
private static readonly object _cacheLock = new();
/// <summary>
/// Look up the style entry for a given (chartType, variant) pair.
/// Returns null when the index has nothing for that key, in which
/// case <see cref="ChartExStyleBuilder"/> falls back to
/// <see cref="MinimalScaffold"/> for every section.
/// </summary>
internal static GalleryEntry? TryGet(string chartType, string variant)
{
var cache = EnsureLoaded();
if (cache == null) return null;
var key = $"{chartType.ToLowerInvariant()}/{variant.ToLowerInvariant()}";
return cache.TryGetValue(key, out var entry) ? entry : null;
}
/// <summary>
/// Expose the set of known (type, variant) keys for diagnostics.
/// </summary>
internal static IReadOnlyCollection<string> KnownKeys()
{
var cache = EnsureLoaded();
return cache?.Keys ?? (IReadOnlyCollection<string>)Array.Empty<string>();
}
private static Dictionary<string, GalleryEntry>? EnsureLoaded()
{
if (_cache != null) return _cache;
lock (_cacheLock)
{
if (_cache != null) return _cache;
_cache = LoadFromEmbeddedResource() ?? new Dictionary<string, GalleryEntry>();
}
return _cache;
}
private static Dictionary<string, GalleryEntry>? LoadFromEmbeddedResource()
{
var assembly = typeof(GalleryIndex).Assembly;
using var stream = assembly.GetManifestResourceStream(IndexResourceName);
if (stream == null)
{
// No index resource embedded — TryGet returns null and the
// builder falls back to minimal scaffolds for every section.
return null;
}
using var doc = JsonDocument.Parse(stream);
var root = doc.RootElement;
if (!root.TryGetProperty("entries", out var entriesEl)
|| entriesEl.ValueKind != JsonValueKind.Object)
{
return null;
}
var result = new Dictionary<string, GalleryEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var entry in entriesEl.EnumerateObject())
{
var key = entry.Name.ToLowerInvariant();
var val = entry.Value;
if (val.ValueKind != JsonValueKind.Object) continue;
int styleId = 410;
if (val.TryGetProperty("styleId", out var styleIdEl)
&& styleIdEl.ValueKind == JsonValueKind.Number)
{
styleId = styleIdEl.GetInt32();
}
var fragMap = new Dictionary<string, string>(StringComparer.Ordinal);
if (val.TryGetProperty("fragments", out var fragsEl)
&& fragsEl.ValueKind == JsonValueKind.Object)
{
foreach (var frag in fragsEl.EnumerateObject())
{
if (frag.Value.ValueKind == JsonValueKind.String)
{
fragMap[frag.Name] = frag.Value.GetString()!;
}
}
}
result[key] = new GalleryEntry(styleId, fragMap);
}
return result;
}
}
/// <summary>
/// Record holding one (chartType, variant) entry: the numeric
/// <c>cs:chartStyle @id</c> and a map from section name to fragment ID.
/// Sections not in the map fall through to <see cref="MinimalScaffold"/>.
/// </summary>
internal sealed record GalleryEntry(
int StyleId,
IReadOnlyDictionary<string, string> Fragments);
/// <summary>
/// Loads individual chartStyle section fragments by their content-hash
/// ID from embedded resources. Fragments are lazily loaded on first
/// request and cached for the process lifetime. Thread-safe via a
/// lock-free <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>.
/// </summary>
internal static class FragmentStore
{
private const string FragmentResourcePrefix =
"OfficeCli.Resources.cx-gallery.fragments.";
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, string?> _cache
= new(StringComparer.Ordinal);
/// <summary>
/// Load the raw XML text of a single chartStyle section fragment
/// by its content-hash ID. Returns null if the fragment isn't
/// embedded — caller (<see cref="ChartExStyleBuilder"/>) then falls
/// back to <see cref="MinimalScaffold.For"/>.
/// </summary>
internal static string? TryLoad(string fragmentId)
{
return _cache.GetOrAdd(fragmentId, LoadFromEmbeddedResource);
}
private static string? LoadFromEmbeddedResource(string fragmentId)
{
var assembly = typeof(FragmentStore).Assembly;
var resourceName = FragmentResourcePrefix + fragmentId + ".xml";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null) return null;
using var reader = new StreamReader(stream, Encoding.UTF8);
return reader.ReadToEnd();
}
}
@@ -0,0 +1,762 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using Drawing = DocumentFormat.OpenXml.Drawing;
using C = DocumentFormat.OpenXml.Drawing.Charts;
namespace OfficeCli.Core;
/// <summary>
/// Advanced chart features: reference lines, conditional coloring, waterfall simulation.
/// </summary>
internal static partial class ChartHelper
{
// ==================== Reference Line ====================
/// <summary>
/// Add a reference (target/average) line to a chart by inserting a hidden line series.
/// Format (positional, ':'-separated):
/// value
/// value:color
/// value:color:label
/// value:color:width:dash (4 parts, if parts[2] is numeric and parts[3] is a known dash style)
/// value:color:label:dash (4 parts, legacy — parts[2] is non-numeric)
/// value:color:width:dash:label (5 parts, canonical — parts[2] may be empty for default width)
/// Width is in points (default 1.5pt). Dash style: solid/dot/dash/dashdot/longdash/longdashdot/longdashdotdot.
/// e.g. "50", "75:FF0000", "100:00AA00:Target", "80:0000FF:Average:dash",
/// "50:FF0000:2.5:dash", "50:FF0000:2:dash:Target", "50:FF0000::dash:Target"
/// </summary>
internal static void AddReferenceLine(C.Chart chart, string spec, bool removeExisting = true)
{
const double DefaultWidthPt = 1.5;
var plotArea = chart.GetFirstChild<C.PlotArea>();
if (plotArea == null) return;
// Caller may suppress the sweep when accumulating multiple lines from
// a semicolon-joined value (see Setter `case "referenceline"`).
if (removeExisting)
RemoveExistingReferenceLines(plotArea);
var parts = spec.Split(':');
if (!double.TryParse(parts[0].Trim(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var refValue))
throw new ArgumentException(
$"Invalid referenceLine value '{parts[0]}'. Expected: number or number:color:label:dash (e.g. '50:FF0000:Target:dash') or number:color:width:dash (e.g. '50:FF0000:2:dash').");
var color = parts.Length > 1 ? parts[1].Trim() : "FF0000";
double widthPt = DefaultWidthPt;
string label = $"Ref ({refValue.ToString("G", System.Globalization.CultureInfo.InvariantCulture)})";
string dash = "dash";
// Positional parse — see doc comment above. parts[0..1] already consumed.
if (parts.Length == 3)
{
label = parts[2].Trim();
}
else if (parts.Length == 4)
{
var p2 = parts[2].Trim();
var p3 = parts[3].Trim();
// Disambiguate: "50:FF0000:2.5:dash" (width form) vs "50:FF0000:Target:dash" (legacy label form).
// Only treat p2 as width if it parses as a number AND p3 is a recognized dash keyword — both
// conditions together make the "ergonomic" width interpretation unambiguous.
if (double.TryParse(p2, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var w4)
&& IsKnownDashStyle(p3))
{
widthPt = w4;
dash = p3;
}
else
{
label = p2;
dash = p3;
}
}
else if (parts.Length >= 5)
{
// Canonical 5-part form: value:color:width:dash:label (extra parts after label are joined
// back with ':' so labels containing literal colons survive a round-trip).
var widthStr = parts[2].Trim();
if (widthStr.Length > 0)
{
if (!double.TryParse(widthStr, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out widthPt))
throw new ArgumentException(
$"Invalid referenceLine width '{widthStr}'. Expected a number in points (e.g. '1.5'), or empty for default {DefaultWidthPt}pt.");
}
dash = parts[3].Trim();
label = string.Join(':', parts.Skip(4)).Trim();
}
if (widthPt <= 0 || widthPt > 100)
throw new ArgumentException(
$"Invalid referenceLine width '{widthPt.ToString("G", System.Globalization.CultureInfo.InvariantCulture)}'. Expected a positive number of points, typically 0.2510.");
// Warn: percent-stacked value axis is 0-1 (displayed 0%-100%). A refValue > 1
// is almost always a mistake — user likely forgot to convert 50 → 0.5.
// Without this check, Excel silently stretches the val axis to fit (e.g. 5000%),
// producing a chart where the real bars are compressed to a thin sliver on the left.
if (refValue > 1.0 && IsPercentStackedChart(plotArea))
{
Console.Error.WriteLine(
$"Warning: referenceLine value {refValue.ToString("G", System.Globalization.CultureInfo.InvariantCulture)} "
+ "on a percent-stacked chart. The value axis is 0-1 (0%-100%); "
+ $"did you mean {(refValue / 100.0).ToString("G", System.Globalization.CultureInfo.InvariantCulture)}? "
+ "Excel will auto-scale the axis to fit, compressing the real bars.");
}
// Find max data point count from existing series (after removing old ref lines)
var existingSerCount = CountSeries(plotArea);
var maxDataPoints = 0;
foreach (var ser in plotArea.Descendants<OpenXmlCompositeElement>().Where(e => e.LocalName == "ser"))
{
var vals = ser.GetFirstChild<C.Values>();
var numLit = vals?.GetFirstChild<C.NumberLiteral>();
var ptCount = numLit?.GetFirstChild<C.PointCount>()?.Val?.Value ?? 0;
if ((int)ptCount > maxDataPoints) maxDataPoints = (int)ptCount;
var numRef = vals?.GetFirstChild<C.NumberReference>();
var cacheCount = numRef?.GetFirstChild<C.NumberingCache>()?.GetFirstChild<C.PointCount>()?.Val?.Value ?? 0;
if ((int)cacheCount > maxDataPoints) maxDataPoints = (int)cacheCount;
}
if (maxDataPoints == 0) maxDataPoints = 3;
// Create a flat line series (all values = refValue)
var refValues = Enumerable.Repeat(refValue, maxDataPoints).ToArray();
var seriesIdx = (uint)existingSerCount;
// Find or create a LineChart in the plot area for the reference line
var lineChart = plotArea.GetFirstChild<C.LineChart>();
if (lineChart == null)
{
// Create a new line chart overlay — shares axes with existing chart
uint catAxisId = 1, valAxisId = 2;
// Try to find existing axis IDs
var existingCatAx = plotArea.GetFirstChild<C.CategoryAxis>()?.GetFirstChild<C.AxisId>()?.Val?.Value;
var existingValAx = plotArea.GetFirstChild<C.ValueAxis>()?.GetFirstChild<C.AxisId>()?.Val?.Value;
if (existingCatAx != null) catAxisId = existingCatAx.Value;
if (existingValAx != null) valAxisId = existingValAx.Value;
lineChart = new C.LineChart(
new C.Grouping { Val = C.GroupingValues.Standard },
new C.VaryColors { Val = false }
);
lineChart.AppendChild(new C.ShowMarker { Val = false });
lineChart.AppendChild(new C.AxisId { Val = catAxisId });
lineChart.AppendChild(new C.AxisId { Val = valAxisId });
// Insert before axes
var firstAxis = plotArea.Elements<C.CategoryAxis>().FirstOrDefault() as OpenXmlElement
?? plotArea.Elements<C.ValueAxis>().FirstOrDefault();
if (firstAxis != null)
plotArea.InsertBefore(lineChart, firstAxis);
else
plotArea.AppendChild(lineChart);
}
// Build the reference line series
var refSer = new C.LineChartSeries();
refSer.AppendChild(new C.Index { Val = seriesIdx });
refSer.AppendChild(new C.Order { Val = seriesIdx });
refSer.AppendChild(new C.SeriesText(new C.NumericValue(label)));
// Style: colored dashed line, no markers. Width is pt → EMU (1pt = 12700 EMU).
var spPr = new C.ChartShapeProperties();
var outline = new Drawing.Outline { Width = (int)Math.Round(widthPt * EmuConverter.EmuPerPoint) };
var sf = new Drawing.SolidFill();
sf.AppendChild(BuildChartColorElement(color));
outline.AppendChild(sf);
outline.AppendChild(new Drawing.PresetDash { Val = ParseDashStyle(dash) });
spPr.AppendChild(outline);
refSer.AppendChild(spPr);
// No marker
refSer.AppendChild(new C.Marker(new C.Symbol { Val = C.MarkerStyleValues.None }));
// Flat data — same value repeated
var numLitRef = new C.NumberLiteral(
new C.FormatCode("General"),
new C.PointCount { Val = (uint)refValues.Length });
for (int i = 0; i < refValues.Length; i++)
numLitRef.AppendChild(new C.NumericPoint(
new C.NumericValue(refValue.ToString("G"))) { Index = (uint)i });
refSer.AppendChild(new C.Values(numLitRef));
// Insert ser before dLbls/dropLines/hiLowLines/upDownBars/marker/smooth/axId
// per CT_LineChart schema: grouping, varyColors, ser*, dLbls?, ...
var insertBeforeEl = lineChart.GetFirstChild<C.DataLabels>() as OpenXmlElement
?? lineChart.GetFirstChild<C.DropLines>()
?? lineChart.GetFirstChild<C.HighLowLines>()
?? lineChart.GetFirstChild<C.UpDownBars>()
?? lineChart.GetFirstChild<C.ShowMarker>()
?? lineChart.GetFirstChild<C.Smooth>()
?? (OpenXmlElement?)lineChart.GetFirstChild<C.AxisId>();
if (insertBeforeEl != null)
lineChart.InsertBefore(refSer, insertBeforeEl);
else
lineChart.AppendChild(refSer);
}
/// <summary>
/// Remove existing reference line series from a plot area.
/// A reference line series is identified as a LineChartSeries in a LineChart
/// where all data points have the same value (flat line), the series has a dashed
/// outline style, and the marker is set to None.
/// </summary>
internal static void RemoveExistingReferenceLines(C.PlotArea plotArea)
{
var lineChart = plotArea.GetFirstChild<C.LineChart>();
if (lineChart == null) return;
var toRemove = new List<C.LineChartSeries>();
foreach (var ser in lineChart.Elements<C.LineChartSeries>())
{
// Check for reference line markers: no marker (None) and dashed outline
var marker = ser.GetFirstChild<C.Marker>();
var markerSymbol = marker?.GetFirstChild<C.Symbol>()?.Val?.Value;
if (markerSymbol != C.MarkerStyleValues.None) continue;
var spPr = ser.GetFirstChild<C.ChartShapeProperties>();
var outline = spPr?.GetFirstChild<Drawing.Outline>();
var hasDash = outline?.GetFirstChild<Drawing.PresetDash>() != null;
if (!hasDash) continue;
// Check if all values are the same (flat line = reference line)
var vals = ser.GetFirstChild<C.Values>();
var numLit = vals?.GetFirstChild<C.NumberLiteral>();
if (numLit != null)
{
var points = numLit.Elements<C.NumericPoint>().Select(p => p.InnerText).Distinct().ToList();
if (points.Count == 1)
toRemove.Add(ser);
}
}
foreach (var ser in toRemove)
ser.Remove();
// If the LineChart is now empty (no series left), remove it entirely
if (!lineChart.Elements<C.LineChartSeries>().Any())
lineChart.Remove();
}
/// <summary>
/// Returns true if any chart in the plot area uses percent-stacked grouping.
/// BarChart/Bar3DChart use BarGrouping; LineChart/AreaChart use Grouping.
/// </summary>
private static bool IsPercentStackedChart(C.PlotArea plotArea)
{
foreach (var el in plotArea.Elements<OpenXmlCompositeElement>())
{
var barGrouping = el.GetFirstChild<C.BarGrouping>()?.Val?.Value;
if (barGrouping == C.BarGroupingValues.PercentStacked) return true;
var grouping = el.GetFirstChild<C.Grouping>()?.Val?.Value;
if (grouping == C.GroupingValues.PercentStacked) return true;
}
return false;
}
/// <summary>
/// Returns true if the given token matches a dash style accepted by ParseDashStyle
/// (see ChartHelper.Setter.cs). Used for the referenceLine numeric-label heuristic.
/// </summary>
private static bool IsKnownDashStyle(string token)
{
return token.ToLowerInvariant() switch
{
"solid" or "dot" or "sysdot" or "dash" or "sysdash"
or "dashdot" or "sysdash_dot" or "sysdashdot"
or "sysdashdotdot" or "sysdash_dot_dot"
or "longdash" or "lgdash"
or "longdashdot" or "lgdashdot"
or "longdashdotdot" or "lgdashdotdot" => true,
_ => false
};
}
// ==================== Conditional Coloring ====================
/// <summary>
/// Apply conditional coloring to data points based on value thresholds.
/// Format: "threshold:belowColor:aboveColor" or "low:lowColor:mid:midColor:high:highColor"
/// Simple: "0:FF0000:00AA00" — below 0 = red, above 0 = green
/// Three-tier: "0:FF0000:50:FFAA00:100:00AA00" — red/orange/green zones
/// </summary>
internal static void ApplyColorRule(C.PlotArea plotArea, string spec)
{
var parts = spec.Split(':');
if (parts.Length < 3)
throw new ArgumentException(
$"Invalid colorRule '{spec}'. Expected: threshold:belowColor:aboveColor (e.g. '0:FF0000:00AA00') " +
"or low:lowColor:mid:midColor:high:highColor (e.g. '0:FF0000:50:FFAA00:100:00AA00').");
var rules = new List<(double threshold, string color)>();
string topColor;
if (parts.Length == 3)
{
// Simple two-zone: threshold:belowColor:aboveColor
if (!double.TryParse(parts[0], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var t))
throw new ArgumentException($"Invalid threshold '{parts[0]}' in colorRule. Expected a number.");
rules.Add((t, parts[1].Trim()));
topColor = parts[2].Trim();
}
else
{
// Multi-zone: t1:c1:t2:c2:...:cN
for (int i = 0; i < parts.Length - 1; i += 2)
{
if (!double.TryParse(parts[i], System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var t))
throw new ArgumentException($"Invalid threshold '{parts[i]}' in colorRule.");
rules.Add((t, parts[i + 1].Trim()));
}
topColor = parts.Length % 2 == 1 ? parts[^1].Trim() : rules[^1].color;
if (parts.Length % 2 == 0)
rules.RemoveAt(rules.Count - 1); // Last pair has no "above" — use as topColor
}
// Apply to each data point in each series
foreach (var ser in plotArea.Descendants<OpenXmlCompositeElement>().Where(e => e.LocalName == "ser"))
{
var values = ReadNumericData(ser.GetFirstChild<C.Values>())
?? ReadNumericData(ser.Elements<OpenXmlCompositeElement>().FirstOrDefault(e => e.LocalName == "yVal"));
if (values == null) continue;
for (int pi = 0; pi < values.Length; pi++)
{
var val = values[pi];
string pointColor = topColor;
foreach (var (threshold, color) in rules)
{
if (val < threshold) { pointColor = color; break; }
pointColor = color; // at or above this threshold, use this color
}
// If above all thresholds, use topColor
if (rules.Count > 0 && val >= rules[^1].threshold)
pointColor = topColor;
ApplyDataPointColor(ser, pi, pointColor);
}
}
}
// ==================== Waterfall Chart (Stacked Bar Simulation) ====================
/// <summary>
/// Build a waterfall chart using stacked bar technique:
/// - Invisible "base" series for the running total
/// - Visible "increase" series (positive changes) and "decrease" series (negative changes)
/// - Last bar shows the total
///
/// Input: categories and a single series of change values.
/// e.g. categories=Revenue,Cost,Tax,Profit data=Cashflow:100,-30,-15,55
/// The last value can be auto-calculated as the total if "auto" or omitted.
/// </summary>
internal static C.ChartSpace BuildWaterfallChart(
string? title,
string[]? categories,
double[] values,
string? increaseColor,
string? decreaseColor,
string? totalColor,
Dictionary<string, string> properties)
{
increaseColor ??= "4472C4"; // blue
decreaseColor ??= "FF0000"; // red
totalColor ??= "2E75B6"; // dark blue
var n = values.Length;
var baseVals = new double[n];
var incVals = new double[n];
var decVals = new double[n];
double running = 0;
for (int i = 0; i < n; i++)
{
var v = values[i];
if (i == n - 1 && properties.GetValueOrDefault("waterfallTotal", "true")
.Equals("true", StringComparison.OrdinalIgnoreCase))
{
// Last bar = total (starts from 0, shows cumulative running total)
// The user's value for the last point is ignored — the total is computed automatically.
baseVals[i] = 0;
incVals[i] = running;
decVals[i] = 0;
}
else if (v >= 0)
{
baseVals[i] = running;
incVals[i] = v;
decVals[i] = 0;
running += v;
}
else
{
baseVals[i] = running + v; // base drops by |v|
incVals[i] = 0;
decVals[i] = -v;
running += v;
}
}
categories ??= Enumerable.Range(1, n).Select(i => i.ToString()).ToArray();
var chartSpace = new C.ChartSpace();
var chart = new C.Chart();
if (!string.IsNullOrEmpty(title))
chart.AppendChild(BuildChartTitle(title));
var plotArea = new C.PlotArea(new C.Layout());
uint catAxisId = 1, valAxisId = 2;
var barChart = new C.BarChart(
new C.BarDirection { Val = C.BarDirectionValues.Column },
new C.BarGrouping { Val = C.BarGroupingValues.Stacked },
new C.VaryColors { Val = false }
);
// Series 0: invisible base
var baseSer = BuildBarSeries(0, "Base", categories, baseVals, null);
// Make base series invisible: no fill, no border
baseSer.RemoveAllChildren<C.ChartShapeProperties>();
var baseSpPr = new C.ChartShapeProperties();
baseSpPr.AppendChild(new Drawing.NoFill());
var baseOutline = new Drawing.Outline();
baseOutline.AppendChild(new Drawing.NoFill());
baseSpPr.AppendChild(baseOutline);
baseSer.InsertAfter(baseSpPr, baseSer.GetFirstChild<C.SeriesText>());
barChart.AppendChild(baseSer);
// Series 1: increase (positive values)
barChart.AppendChild(BuildBarSeries(1, "Increase", categories, incVals, increaseColor));
// Series 2: decrease (negative values)
barChart.AppendChild(BuildBarSeries(2, "Decrease", categories, decVals, decreaseColor));
barChart.AppendChild(new C.GapWidth { Val = 80 });
barChart.AppendChild(new C.Overlap { Val = 100 });
barChart.AppendChild(new C.AxisId { Val = catAxisId });
barChart.AppendChild(new C.AxisId { Val = valAxisId });
plotArea.AppendChild(barChart);
plotArea.AppendChild(BuildCategoryAxis(catAxisId, valAxisId));
plotArea.AppendChild(BuildValueAxis(valAxisId, catAxisId, C.AxisPositionValues.Left));
chart.AppendChild(plotArea);
// Hide base series from legend
var legend = new C.Legend(
new C.LegendPosition { Val = C.LegendPositionValues.Bottom },
new C.Overlay { Val = false }
);
// Delete legend entry for base series (index 0)
// CT_Legend schema order: legendPos, legendEntry+, layout, overlay — insert after legendPos
var leBase = new C.LegendEntry();
leBase.AppendChild(new C.Index { Val = 0 });
leBase.AppendChild(new C.Delete { Val = true });
var legendPosEl = legend.GetFirstChild<C.LegendPosition>();
if (legendPosEl != null)
legendPosEl.InsertAfterSelf(leBase);
else
legend.PrependChild(leBase);
chart.AppendChild(legend);
chart.AppendChild(new C.PlotVisibleOnly { Val = true });
chart.AppendChild(new C.DisplayBlanksAs { Val = C.DisplayBlanksAsValues.Gap });
chartSpace.AppendChild(chart);
// Color the total bar differently (last data point of increase series)
if (properties.GetValueOrDefault("waterfallTotal", "true")
.Equals("true", StringComparison.OrdinalIgnoreCase) && n > 0)
{
var allSer = plotArea.Descendants<OpenXmlCompositeElement>()
.Where(e => e.LocalName == "ser").ToList();
if (allSer.Count >= 2)
ApplyDataPointColor(allSer[1], n - 1, totalColor);
}
return chartSpace;
}
// ==================== Flexible Combo Chart ====================
/// <summary>
/// Build a combo chart with per-series chart type assignment.
/// comboTypes property: "column,column,line,area" — one type per series.
/// </summary>
internal static void RebuildComboChart(C.Chart chart, string comboTypes)
{
var plotArea = chart.GetFirstChild<C.PlotArea>();
if (plotArea == null) return;
var typeList = comboTypes.Split(',').Select(t => t.Trim().ToLowerInvariant()).ToArray();
// Validate every token BEFORE any mutation: unknown tokens used to fall
// through to the default LineChart arm, silently coercing garbage
// (combotypes=asdf,qwer) into line,line — the only mini-language prop
// that accepted typos. Also keeps the rebuild atomic on bad input.
foreach (var t in typeList)
{
var baseToken = t.EndsWith("percentstacked", StringComparison.Ordinal) ? t[..^14]
: t.EndsWith("stacked", StringComparison.Ordinal) ? t[..^7]
: t;
if (baseToken is not ("bar" or "column" or "col" or "line" or "area" or "scatter"))
throw new ArgumentException(
$"Invalid comboTypes token '{t}'. Expected bar/column/line/area/scatter, " +
"optionally with a stacked/percentstacked suffix (e.g. 'column,line' or 'columnstacked,line').");
}
// Read all existing series data
var allSer = plotArea.Descendants<OpenXmlCompositeElement>()
.Where(e => e.LocalName == "ser").ToList();
if (allSer.Count == 0) return;
// Read series data
var seriesInfo = new List<(OpenXmlCompositeElement original, string targetType)>();
for (int i = 0; i < allSer.Count; i++)
{
var targetType = i < typeList.Length ? typeList[i] : typeList[^1];
seriesInfo.Add((allSer[i], targetType));
}
// Find axis IDs
uint catAxisId = plotArea.GetFirstChild<C.CategoryAxis>()?.GetFirstChild<C.AxisId>()?.Val?.Value ?? 1;
uint valAxisId = plotArea.GetFirstChild<C.ValueAxis>()?.GetFirstChild<C.AxisId>()?.Val?.Value ?? 2;
// Remove existing chart type elements (but keep axes, layout, etc.)
foreach (var ct in plotArea.ChildElements
.Where(e => e.LocalName.EndsWith("Chart") || e.LocalName.EndsWith("chart"))
.OfType<OpenXmlCompositeElement>().ToList())
{
ct.Remove();
}
// R26-1 — drop any SECONDARY axis declarations (catAx/valAx with an id
// other than the primary cat/val ids, i.e. the 3/4 pair created by a
// prior ApplySecondaryAxis). The rebuild below re-binds every series to
// the primary axIds, so a leftover secondary axis would be referenced by
// no chart container and Excel rejects the orphaned declaration. This is
// order-independent defense: it holds even if secondaryaxis somehow runs
// before combotypes (the PropOrder=0 schedule normally prevents that).
foreach (var ax in plotArea.ChildElements
.Where(e => e.LocalName is "catAx" or "valAx" or "serAx" or "dateAx")
.OfType<OpenXmlCompositeElement>().ToList())
{
var id = ax.GetFirstChild<C.AxisId>()?.Val?.Value;
if (id.HasValue && id.Value != catAxisId && id.Value != valAxisId)
ax.Remove();
}
// Group series by target chart type
var groups = seriesInfo.GroupBy(s => s.targetType).ToList();
foreach (var group in groups)
{
// Grouping-qualified tokens (columnstacked / areapercentstacked …)
// — parse the suffix so a stacked combo group doesn't rebuild as
// clustered/standard (and doesn't fall through to the default
// LineChart branch).
var groupToken = group.Key;
string comboGrpSuffix = "";
if (groupToken.EndsWith("percentstacked", StringComparison.Ordinal))
{ comboGrpSuffix = "percentstacked"; groupToken = groupToken[..^14]; }
else if (groupToken.EndsWith("stacked", StringComparison.Ordinal))
{ comboGrpSuffix = "stacked"; groupToken = groupToken[..^7]; }
var comboBarGrp = comboGrpSuffix switch
{
"percentstacked" => C.BarGroupingValues.PercentStacked,
"stacked" => C.BarGroupingValues.Stacked,
_ => C.BarGroupingValues.Clustered,
};
var comboStdGrp = comboGrpSuffix switch
{
"percentstacked" => C.GroupingValues.PercentStacked,
"stacked" => C.GroupingValues.Stacked,
_ => C.GroupingValues.Standard,
};
OpenXmlCompositeElement chartTypeEl;
switch (groupToken)
{
case "bar":
chartTypeEl = new C.BarChart(
new C.BarDirection { Val = C.BarDirectionValues.Bar },
new C.BarGrouping { Val = comboBarGrp },
new C.VaryColors { Val = false });
break;
case "column" or "col":
chartTypeEl = new C.BarChart(
new C.BarDirection { Val = C.BarDirectionValues.Column },
new C.BarGrouping { Val = comboBarGrp },
new C.VaryColors { Val = false });
break;
case "line":
chartTypeEl = new C.LineChart(
new C.Grouping { Val = comboStdGrp },
new C.VaryColors { Val = false });
break;
case "area":
chartTypeEl = new C.AreaChart(
new C.Grouping { Val = comboStdGrp },
new C.VaryColors { Val = false });
break;
case "scatter":
chartTypeEl = new C.ScatterChart(
new C.ScatterStyle { Val = C.ScatterStyleValues.LineMarker },
new C.VaryColors { Val = false });
break;
default:
chartTypeEl = new C.LineChart(
new C.Grouping { Val = comboStdGrp },
new C.VaryColors { Val = false });
break;
}
foreach (var (original, _) in group)
{
// Don't clone original directly — original is a BarChartSeries, but
// chartTypeEl may be LineChart/AreaChart/ScatterChart which require
// LineChartSeries / AreaChartSeries / ScatterChartSeries respectively.
// Schema validation rejects mismatched series. Convert to the right type.
chartTypeEl.AppendChild(ConvertSeriesToType(original, groupToken));
}
// Bar/column groups get the same explicit gapWidth the builder
// stamps (150, the spec default). Omitting it renders identically
// today but made a combotypes-rebuilt chart differ from a
// directly-built one, breaking first-round dump idempotency.
if (chartTypeEl is C.BarChart)
chartTypeEl.AppendChild(new C.GapWidth { Val = 150 });
chartTypeEl.AppendChild(new C.AxisId { Val = catAxisId });
chartTypeEl.AppendChild(new C.AxisId { Val = valAxisId });
// Insert before axes
var firstAxis = plotArea.Elements<C.CategoryAxis>().FirstOrDefault() as OpenXmlElement
?? plotArea.Elements<C.ValueAxis>().FirstOrDefault();
if (firstAxis != null)
plotArea.InsertBefore(chartTypeEl, firstAxis);
else
plotArea.AppendChild(chartTypeEl);
}
}
/// <summary>
/// Convert a chart series element (BarChartSeries, LineChartSeries, etc.) to the
/// series type required by a target chart type (bar/column/line/area/scatter).
/// The OOXML schema requires each chart container to host its own series subclass —
/// a LineChart cannot host a BarChartSeries even though the field set is identical.
/// Copies idx, order, tx, spPr, cat, val (and x/yVal for scatter) from the source.
/// </summary>
private static OpenXmlCompositeElement ConvertSeriesToType(OpenXmlCompositeElement source, string targetType)
{
// Extract identity + data children by local name so we can move them across
// schema namespaces without depending on the source's concrete type.
OpenXmlElement? Take(string localName)
{
return source.ChildElements.FirstOrDefault(e => e.LocalName == localName);
}
var idx = Take("idx");
var order = Take("order");
var tx = Take("tx");
var spPr = Take("spPr");
var marker = Take("marker");
var cat = Take("cat");
var val = Take("val");
var xVal = Take("xVal");
var yVal = Take("yVal");
var smooth = Take("smooth");
var invertIfNegative = Take("invertIfNegative");
OpenXmlCompositeElement target = targetType switch
{
"bar" or "column" or "col" => new C.BarChartSeries(),
"line" => new C.LineChartSeries(),
"area" => new C.AreaChartSeries(),
"scatter" => new C.ScatterChartSeries(),
_ => new C.LineChartSeries(),
};
// CT_SerXxx schema order: idx, order, tx, spPr, [invertIfNegative|marker], [dPt*],
// [dLbls], [trendline*], [errBars], cat, val (or xVal/yVal for scatter), smooth.
if (idx != null) target.AppendChild(idx.CloneNode(true));
if (order != null) target.AppendChild(order.CloneNode(true));
if (tx != null) target.AppendChild(tx.CloneNode(true));
if (spPr != null)
{
var spPrClone = (OpenXmlCompositeElement)spPr.CloneNode(true);
// Line-based series carry their color on the stroke
// (<a:ln><a:solidFill>); a bare <a:solidFill> cloned from an
// area/bar source is stroke-inert (real Office ignores it and
// renders the theme color), AND the dump reads it as the series
// color and replays it as an <a:ln> stroke — a first-round
// dump→replay drift. Rewrap the fill as the stroke here.
if (targetType is "line" or "scatter")
{
var bareFill = spPrClone.ChildElements
.FirstOrDefault(e => e.LocalName == "solidFill");
var hasLn = spPrClone.ChildElements.Any(e => e.LocalName == "ln");
if (bareFill != null && !hasLn)
{
bareFill.Remove();
var outline = new Drawing.Outline { Width = 25400 }; // 2pt, same as ApplySeriesColor
outline.AppendChild(bareFill);
// CT_ShapeProperties order puts <a:ln> after the fill
// group; the fill was just removed, so insert before any
// effect/3d/ext tail else append.
var lnBefore = spPrClone.ChildElements.FirstOrDefault(e =>
e.LocalName is "effectLst" or "effectDag" or "scene3d" or "sp3d" or "extLst");
if (lnBefore != null) spPrClone.InsertBefore(outline, lnBefore);
else spPrClone.AppendChild(outline);
}
}
target.AppendChild(spPrClone);
}
// invertIfNegative only valid on bar series; marker on line/scatter
if (targetType is "bar" or "column" or "col")
{
if (invertIfNegative != null) target.AppendChild(invertIfNegative.CloneNode(true));
}
else if (targetType is "line" or "scatter")
{
if (marker != null) target.AppendChild(marker.CloneNode(true));
}
if (targetType == "scatter")
{
// Scatter needs xVal + yVal; synthesize from cat/val if source was non-scatter.
if (xVal != null)
target.AppendChild(xVal.CloneNode(true));
else if (cat != null)
{
// Reuse cat data as numeric x-values where possible; otherwise omit.
// Scatter without xVal is legal — Excel auto-indexes.
}
if (yVal != null) target.AppendChild(yVal.CloneNode(true));
else if (val != null)
{
// Convert c:val -> c:yVal by re-parenting the numRef/numLit child.
var inner = val.ChildElements.FirstOrDefault(e =>
e.LocalName == "numRef" || e.LocalName == "numLit");
if (inner != null)
target.AppendChild(new C.YValues(inner.CloneNode(true)));
}
}
else
{
if (cat != null) target.AppendChild(cat.CloneNode(true));
if (val != null) target.AppendChild(val.CloneNode(true));
}
if (targetType is "line" or "scatter" && smooth != null)
target.AppendChild(smooth.CloneNode(true));
return target;
}
}
@@ -0,0 +1,913 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using DocumentFormat.OpenXml.Packaging;
using Drawing = DocumentFormat.OpenXml.Drawing;
using C = DocumentFormat.OpenXml.Drawing.Charts;
namespace OfficeCli.Core;
internal static partial class ChartHelper
{
// ==================== Axis by @role path routing ====================
//
// Surfaces /chart[N]/axis[@role=ROLE] where ROLE ∈ {category, value, value2, series}.
// Per schemas/help/pptx/chart-axis.json. Shared across Pptx / Word / Excel handlers.
/// <summary>
/// Locate the C.* axis element in the plot area corresponding to the given role.
/// Returns null if not present.
/// </summary>
private static OpenXmlElement? FindAxisByRole(C.PlotArea plotArea, string role)
{
switch (role.ToLowerInvariant())
{
case "category":
return (OpenXmlElement?)plotArea.Elements<C.CategoryAxis>().FirstOrDefault()
?? plotArea.Elements<C.DateAxis>().FirstOrDefault();
case "value":
return plotArea.Elements<C.ValueAxis>().FirstOrDefault();
case "value2":
return plotArea.Elements<C.ValueAxis>().Skip(1).FirstOrDefault();
case "series":
return plotArea.Elements<C.SeriesAxis>().FirstOrDefault();
default:
return null;
}
}
/// <summary>
/// Build a DocumentNode describing the axis identified by <paramref name="role"/>.
/// Returns null if the chart has no plot area or no matching axis.
/// </summary>
internal static DocumentNode? BuildAxisNode(C.ChartSpace chartSpace, string role, string path)
{
var chart = chartSpace?.GetFirstChild<C.Chart>();
var plotArea = chart?.GetFirstChild<C.PlotArea>();
if (plotArea == null) return null;
var axis = FindAxisByRole(plotArea, role);
if (axis == null) return null;
var node = new DocumentNode { Path = path, Type = "axis" };
node.Format["role"] = role.ToLowerInvariant();
// Title (axis own title, not chart title)
var axisTitle = axis.GetFirstChild<C.Title>();
var axisTitleText = axisTitle?.Descendants<Drawing.Text>().FirstOrDefault()?.Text;
if (axisTitleText != null) node.Format["title"] = axisTitleText;
// CONSISTENCY(axis-title-styling): mirror the Set surface — when callers
// can write title.font/color/size on the axis Set path, they must also
// be able to read them back on the axis Get. Pull from the first run's
// rPr (and fall back to defRPr) so the readback matches what was set.
if (axisTitle != null)
{
var firstAxisTitleRun = axisTitle.Descendants<Drawing.Run>().FirstOrDefault();
var firstAxisRPr = firstAxisTitleRun?.RunProperties;
var axisDefRPr = axisTitle.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
var atFont = firstAxisRPr?.GetFirstChild<Drawing.LatinFont>()?.Typeface?.Value
?? axisDefRPr?.GetFirstChild<Drawing.LatinFont>()?.Typeface?.Value;
if (!string.IsNullOrEmpty(atFont)) node.Format["title.font"] = atFont;
var atSize = firstAxisRPr?.FontSize?.Value ?? axisDefRPr?.FontSize?.Value;
if (atSize.HasValue)
node.Format["title.size"] = $"{atSize.Value / 100.0:0.##}pt";
var atBold = firstAxisRPr?.Bold?.Value ?? axisDefRPr?.Bold?.Value;
if (atBold == true) node.Format["title.bold"] = "true";
// Color from rPr's solidFill (or defRPr's). Mirror ParseHelpers
// canonical "#RRGGBB" used elsewhere in chart readback.
var atSolid = firstAxisRPr?.GetFirstChild<Drawing.SolidFill>()
?? axisDefRPr?.GetFirstChild<Drawing.SolidFill>();
var atRgbEl = atSolid?.GetFirstChild<Drawing.RgbColorModelHex>();
if (atRgbEl?.Val?.Value is { } atRgb && !string.IsNullOrEmpty(atRgb))
node.Format["title.color"] = ParseHelpers.FormatHexColor(atRgb);
}
// Visible: true unless C.Delete is set truthy
var deleteEl = axis.GetFirstChild<C.Delete>();
var deleted = deleteEl?.Val?.Value == true;
node.Format["visible"] = (!deleted).ToString().ToLowerInvariant();
// Date-axis base time unit (days/months/years) — drives the tick
// spacing of the plotted range; a years-based source replayed as
// days renders hairline bars.
if (axis is C.DateAxis dateAxEl)
{
var btu = dateAxEl.GetFirstChild<C.BaseTimeUnit>()?.Val;
if (btu?.HasValue == true)
node.Format["baseTimeUnit"] = btu.InnerText;
}
// Scaling min/max — meaningful on value axes and on a DateAxis
// (its min/max are date serials that window the plotted range).
if (role.Equals("value", StringComparison.OrdinalIgnoreCase)
|| role.Equals("value2", StringComparison.OrdinalIgnoreCase)
|| (role.Equals("category", StringComparison.OrdinalIgnoreCase)
&& axis is C.DateAxis))
{
var scaling = axis.GetFirstChild<C.Scaling>();
var minEl = scaling?.GetFirstChild<C.MinAxisValue>();
var maxEl = scaling?.GetFirstChild<C.MaxAxisValue>();
if (minEl?.Val?.HasValue == true)
node.Format["min"] = minEl.Val.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (maxEl?.Val?.HasValue == true)
node.Format["max"] = maxEl.Val.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var logBaseEl = scaling?.GetFirstChild<C.LogBase>();
if (logBaseEl?.Val?.HasValue == true)
node.Format["logBase"] = logBaseEl.Val.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
// MajorUnit/MinorUnit — value axis tick intervals (axis-level reader; mirrors Setter mutation)
var majorUnitEl = axis.GetFirstChild<C.MajorUnit>();
if (majorUnitEl?.Val?.HasValue == true)
node.Format["majorUnit"] = majorUnitEl.Val.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
var minorUnitEl = axis.GetFirstChild<C.MinorUnit>();
if (minorUnitEl?.Val?.HasValue == true)
node.Format["minorUnit"] = minorUnitEl.Val.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
// DisplayUnits — value axis label units (axis-level reader; chart-level Reader emits same key)
var dispUnitsEl = axis.GetFirstChild<C.DisplayUnits>();
var builtInUnit = dispUnitsEl?.GetFirstChild<C.BuiltInUnit>()?.Val;
if (builtInUnit?.HasValue == true)
node.Format["dispUnits"] = builtInUnit.InnerText;
}
// NumberingFormat — applies to any axis role per schema (chart-axis.json `format`)
var numFmt = axis.GetFirstChild<C.NumberingFormat>()?.FormatCode?.Value;
if (numFmt != null && numFmt != "General") node.Format["format"] = numFmt;
// Gridline presence + detail. Mirror the chart-level Reader: presence is a
// boolean, but the gridline's solidFill color / outline width / dash are
// surfaced via ReadGridlineDetail so a `majorgridlines=COLOR:WIDTH` Set is
// visible on Get (gridlineColor / gridlineWidth / gridlineDash).
var majorGridlines = axis.GetFirstChild<C.MajorGridlines>();
node.Format["majorGridlines"] = (majorGridlines != null).ToString().ToLowerInvariant();
if (majorGridlines != null) ReadGridlineDetail(majorGridlines, node, "gridline");
var minorGridlines = axis.GetFirstChild<C.MinorGridlines>();
node.Format["minorGridlines"] = (minorGridlines != null).ToString().ToLowerInvariant();
if (minorGridlines != null) ReadGridlineDetail(minorGridlines, node, "minorGridline");
// Axis orientation (value/category — schema applies to both via scaling)
var scalingForOrient = axis.GetFirstChild<C.Scaling>();
var axisOrient = scalingForOrient?.GetFirstChild<C.Orientation>()?.Val;
if (axisOrient?.HasValue == true && axisOrient.InnerText == "maxMin")
node.Format["axisOrientation"] = "maxMin";
// Tick marks — mirror chart-level reader (R43-1)
var majorTick = axis.GetFirstChild<C.MajorTickMark>()?.Val;
if (majorTick?.HasValue == true) node.Format["majorTickMark"] = majorTick.InnerText;
var minorTick = axis.GetFirstChild<C.MinorTickMark>()?.Val;
if (minorTick?.HasValue == true) node.Format["minorTickMark"] = minorTick.InnerText;
// Tick label position
var tickLblPos = axis.GetFirstChild<C.TickLabelPosition>()?.Val;
if (tickLblPos?.HasValue == true) node.Format["tickLabelPos"] = tickLblPos.InnerText;
// Crossing (value axis vocabulary; on category axis these are inert) — R43-2
if (axis is OpenXmlCompositeElement axCross)
{
var crossesVal = axCross.GetFirstChild<C.Crosses>()?.Val;
if (crossesVal?.HasValue == true) node.Format["crosses"] = crossesVal.InnerText;
var crossesAtVal = axCross.GetFirstChild<C.CrossesAt>()?.Val?.Value;
if (crossesAtVal != null) node.Format["crossesAt"] = crossesAtVal;
var crossBetween = axCross.GetFirstChild<C.CrossBetween>()?.Val;
if (crossBetween?.HasValue == true) node.Format["crossBetween"] = crossBetween.InnerText;
}
// Category-axis specifics — labelOffset, tickLabelSkip
if (role.Equals("category", StringComparison.OrdinalIgnoreCase))
{
var labelOffsetVal = axis.GetFirstChild<C.LabelOffset>()?.Val?.Value;
if (labelOffsetVal != null && labelOffsetVal != 100)
node.Format["labelOffset"] = labelOffsetVal;
var tickLblSkipVal = axis.GetFirstChild<C.TickLabelSkip>()?.Val?.Value;
if (tickLblSkipVal != null && tickLblSkipVal > 1)
node.Format["tickLabelSkip"] = tickLblSkipVal;
}
// Label rotation from TextProperties BodyProperties.Rotation (60000 per degree)
var txPr = axis.GetFirstChild<C.TextProperties>();
var bodyPr = txPr?.GetFirstChild<Drawing.BodyProperties>();
if (bodyPr?.Rotation?.HasValue == true)
{
var deg = bodyPr.Rotation.Value / 60000.0;
node.Format["labelRotation"] = deg.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture);
}
return node;
}
/// <summary>
/// Translate role-scoped Set properties into the existing dotted-key vocabulary
/// consumed by <see cref="SetChartProperties(ChartPart, Dictionary{string, string})"/>
/// and forward the call. Returns the list of unsupported keys.
/// </summary>
internal static List<string> SetAxisProperties(
ChartPart chartPart, string role, Dictionary<string, string> properties)
{
var normalizedRole = role.ToLowerInvariant();
var translated = new Dictionary<string, string>();
var directlyHandled = new List<string>();
var pendingAxisTitleStyling = new Dictionary<string, string>(System.StringComparer.OrdinalIgnoreCase);
// Resolve target axis once for direct-apply paths.
var chart = chartPart.ChartSpace?.GetFirstChild<C.Chart>();
var plotArea = chart?.GetFirstChild<C.PlotArea>();
var targetAxis = plotArea != null ? FindAxisByRole(plotArea, normalizedRole) : null;
foreach (var (key, value) in properties)
{
var lower = key.ToLowerInvariant();
switch (lower)
{
case "title":
case "axistitle":
case "vtitle":
// Map role → existing axis-title keys already handled by SetChartProperties.
// category/series → cattitle; value → axistitle (primary value axis).
// The common alias `axisTitle` (and `vtitle`) must route here
// too — otherwise it falls through to the default and always
// targets the PRIMARY value axis, clobbering it for role=value2.
if (normalizedRole is "category" or "series")
translated["cattitle"] = value;
// CONSISTENCY(chart/axis-role-write): the legacy `axistitle`
// key always targets the PRIMARY value axis. For role=value2
// that would overwrite the primary axis's title and leave the
// secondary untouched — write directly to the resolved
// secondary axis instead (mirrors min/max/crosses below).
else if (normalizedRole == "value2" && targetAxis is OpenXmlCompositeElement titleAx2)
{
titleAx2.RemoveAllChildren<C.Title>();
if (!value.Equals("none", StringComparison.OrdinalIgnoreCase))
{
ParseHelpers.ValidateXmlText(value, "axisTitle");
var insertAfter = (OpenXmlElement?)titleAx2.GetFirstChild<C.MinorGridlines>()
?? (OpenXmlElement?)titleAx2.GetFirstChild<C.MajorGridlines>()
?? titleAx2.GetFirstChild<C.AxisPosition>();
var newTitle = BuildChartTitle(value);
if (insertAfter != null) titleAx2.InsertAfter(newTitle, insertAfter);
else titleAx2.AppendChild(newTitle);
}
directlyHandled.Add(key);
}
else
translated["axistitle"] = value;
break;
case "min":
// CONSISTENCY(chart/axis-role-write): the legacy `axismin` key
// always targets the primary value axis. For role=value2 we must
// write to the secondary axis directly to mirror BuildAxisNode's
// Skip(1) read path. Same for max/crosses/crossesat below.
// Category (date) and series axes must also write directly:
// the legacy `axismin` fallback targets the primary VALUE
// axis, which would clobber the wrong scaling.
if (normalizedRole is "value2" or "category" or "series"
&& targetAxis is OpenXmlCompositeElement minAx2)
{
var scaling = minAx2.GetFirstChild<C.Scaling>();
if (scaling != null)
{
var minV = ParseHelpers.SafeParseDouble(value, "min");
// A log-scaled axis cannot have min <= 0 (Excel
// refuses the file, 0x800A03EC).
if (minV <= 0 && scaling.GetFirstChild<C.LogBase>() != null)
throw new ArgumentException(
$"min={value} is invalid on a log-scaled axis: a logarithmic axis minimum must be greater than 0.");
scaling.RemoveAllChildren<C.MinAxisValue>();
// CT_Scaling order: logBase, orientation, max, min —
// min is last, so append is always valid.
scaling.AppendChild(new C.MinAxisValue { Val = minV });
}
directlyHandled.Add(key);
}
else
{
translated["axismin"] = value;
}
break;
case "max":
if (normalizedRole is "value2" or "category" or "series"
&& targetAxis is OpenXmlCompositeElement maxAx2)
{
var scaling = maxAx2.GetFirstChild<C.Scaling>();
if (scaling != null)
{
scaling.RemoveAllChildren<C.MaxAxisValue>();
var maxEl = new C.MaxAxisValue { Val = ParseHelpers.SafeParseDouble(value, "max") };
// Schema order: logBase?, orientation, max?, min? — insert max after orientation
var orient = scaling.GetFirstChild<C.Orientation>();
if (orient != null) orient.InsertAfterSelf(maxEl);
else scaling.PrependChild(maxEl);
}
directlyHandled.Add(key);
}
else
{
translated["axismax"] = value;
}
break;
case "crosses":
if (normalizedRole == "value2" && targetAxis is OpenXmlCompositeElement crsAx2)
{
// Same-type only — see ChartHelper.Setter.cs case "crosses"
// for the mutual-remove bug rationale.
// Validate BEFORE mutating (atomicity).
var crossVal = value.ToLowerInvariant() switch
{
"max" => C.CrossesValues.Maximum,
"min" => C.CrossesValues.Minimum,
"autozero" => C.CrossesValues.AutoZero,
_ => throw new ArgumentException($"Invalid 'crosses' value: '{value}'. Valid: autoZero, max, min.")
};
crsAx2.RemoveAllChildren<C.Crosses>();
var newCrosses = new C.Crosses { Val = crossVal };
var crsAnchor = crsAx2.GetFirstChild<C.CrossesAt>() as OpenXmlElement
?? crsAx2.GetFirstChild<C.CrossBetween>() as OpenXmlElement;
if (crsAnchor != null) crsAx2.InsertBefore(newCrosses, crsAnchor);
else crsAx2.AppendChild(newCrosses);
directlyHandled.Add(key);
}
else
{
translated["crosses"] = value;
}
break;
case "crossesat":
if (normalizedRole == "value2" && targetAxis is OpenXmlCompositeElement crsAtAx2)
{
// Same-type only.
var crossesAtVal2 = ParseHelpers.SafeParseDouble(value, "crossesAt");
crsAtAx2.RemoveAllChildren<C.CrossesAt>();
var newCrossesAt = new C.CrossesAt { Val = crossesAtVal2 };
var cbBefore2 = crsAtAx2.GetFirstChild<C.CrossBetween>();
if (cbBefore2 != null) crsAtAx2.InsertBefore(newCrossesAt, cbBefore2);
else crsAtAx2.AppendChild(newCrossesAt);
// CONSISTENCY(chart/crossesat-overrides-crosses): mirror
// ChartHelper.Setter.cs case "crossesat" — suppress the
// default <c:crosses val="autoZero"/> seeded by the axis
// builder when the caller did not request `crosses`
// explicitly, so dump→replay does not surface a
// spurious crosses=autoZero (R57 tester-1).
if (!properties.ContainsKey("crosses"))
crsAtAx2.RemoveAllChildren<C.Crosses>();
directlyHandled.Add(key);
}
else
{
translated["crossesat"] = value;
}
break;
case "labelrotation":
// CONSISTENCY(chart/axis-role-write): the legacy
// xaxis.labelrotation / yaxis.labelrotation keys target the
// CategoryAxis(es) / primary ValueAxis respectively, ignoring
// role. For value2 (secondary value axis) the legacy
// yaxis.labelrotation stamps EVERY ValueAxis, corrupting the
// primary; for series it would route to xaxis.labelrotation and
// mutate the CategoryAxis instead of the SeriesAxis. Apply
// directly on the resolved target axis to mirror BuildAxisNode's
// per-axis labelRotation read.
if (normalizedRole is "value2" or "series"
&& targetAxis is OpenXmlCompositeElement axRot)
{
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var rotDeg))
{
var rotAttrVal = ((int)(rotDeg * 60000))
.ToString(System.Globalization.CultureInfo.InvariantCulture);
ApplyAxisLabelRotation(axRot, rotAttrVal);
directlyHandled.Add(key);
}
else
{
translated[key] = value; // let SetChartProperties flag the bad value
}
}
else
{
// Existing setter already understands xaxis.labelrotation / yaxis.labelrotation.
translated[normalizedRole is "category"
? "xaxis.labelrotation"
: "yaxis.labelrotation"] = value;
}
break;
case "visible":
// Map by role to the existing role-specific cataxisvisible/valaxisvisible
// keys. value/value2/series are not split in the legacy setter, so for
// value2 we apply directly on the resolved axis.
if (normalizedRole is "category")
translated["cataxisvisible"] = value;
else if (normalizedRole is "value" or "series")
translated["valaxisvisible"] = value;
else if (targetAxis is OpenXmlCompositeElement axCe)
{
axCe.RemoveAllChildren<C.Delete>();
axCe.InsertAfter(
new C.Delete { Val = !ParseHelpers.IsTruthy(value) },
axCe.GetFirstChild<C.Scaling>());
directlyHandled.Add(key);
}
else
{
directlyHandled.Add(key); // axis missing; treat as no-op silently
}
break;
case "majortickmark":
case "minortickmark":
case "majortick":
case "minortick":
{
// CONSISTENCY(chart/axis-role-write): legacy SetChartProperties
// applies tickmark to every ValueAxis and CategoryAxis. Under a
// role-scoped write we must only touch the resolved axis.
if (targetAxis is OpenXmlCompositeElement axTick)
{
var tickVal = ParseTickMark(value);
if (lower == "majortickmark" || lower == "majortick")
{
axTick.RemoveAllChildren<C.MajorTickMark>();
InsertAxisChildInOrder(axTick, new C.MajorTickMark { Val = tickVal });
}
else
{
axTick.RemoveAllChildren<C.MinorTickMark>();
InsertAxisChildInOrder(axTick, new C.MinorTickMark { Val = tickVal });
}
}
directlyHandled.Add(key);
break;
}
case "logbase":
{
// Schema: logBase only valid on role=value/value2; category/series → ignore.
if (normalizedRole is not ("value" or "value2"))
{
directlyHandled.Add(key);
break;
}
if (targetAxis is OpenXmlCompositeElement axLb)
{
var scaling = axLb.GetFirstChild<C.Scaling>();
if (scaling != null)
{
// Resolve+validate BEFORE mutating so a bad numeric
// base doesn't wipe the prior valid log scale.
double? newLogBase;
if (value.Equals("true", StringComparison.OrdinalIgnoreCase) ||
value.Equals("yes", StringComparison.OrdinalIgnoreCase) ||
value.Equals("log", StringComparison.OrdinalIgnoreCase))
{
// "1" was historically truthy shorthand here too;
// routed through SafeParseDouble + range-check below
// so logBase=1 surfaces as ArgumentException.
newLogBase = 10d;
}
else if (value.Equals("none", StringComparison.OrdinalIgnoreCase) ||
value.Equals("linear", StringComparison.OrdinalIgnoreCase) ||
value.Equals("false", StringComparison.OrdinalIgnoreCase) ||
value.Equals("no", StringComparison.OrdinalIgnoreCase))
{
newLogBase = null; // remove log scale (linear)
}
else
// "0" dropped as a falsy synonym for the same
// reason as the Setter.cs site — falls into the
// range check below and throws.
{
var logVal = ParseHelpers.SafeParseDouble(value, "logBase");
// ST_LogBase: minInclusive=2.0, maxInclusive=1000.0 — reject
// out-of-band values so Excel doesn't silently
// ghost-rewrite the chart back to linear.
if (logVal < 2.0 || logVal > 1000.0)
throw new ArgumentException($"Invalid logBase '{value}': must be in the OOXML range [2, 1000] (ST_LogBase).");
newLogBase = logVal;
}
// A log scale requires axis min > 0 (Excel refuses
// the file, 0x800A03EC).
if (newLogBase != null
&& scaling.GetFirstChild<C.MinAxisValue>()?.Val?.Value is { } curMin && curMin <= 0)
throw new ArgumentException(
$"logBase cannot be enabled while the axis minimum ({curMin}) is <= 0: a logarithmic axis minimum must be greater than 0.");
scaling.RemoveAllChildren<C.LogBase>();
if (newLogBase != null)
scaling.PrependChild(new C.LogBase { Val = newLogBase.Value });
}
}
directlyHandled.Add(key);
break;
}
case "format":
{
// Number-format string written as the axis's NumberingFormat child.
// Schema declares format on all roles; apply directly on the resolved axis.
if (targetAxis is OpenXmlCompositeElement axNf)
{
axNf.RemoveAllChildren<C.NumberingFormat>();
var nf = new C.NumberingFormat { FormatCode = value, SourceLinked = false };
// Schema order: ...title, numFmt, majorTickMark... — insert before majorTickMark
var nfBefore = axNf.GetFirstChild<C.MajorTickMark>();
if (nfBefore != null) axNf.InsertBefore(nf, nfBefore);
else axNf.AppendChild(nf);
// Date axis: real PowerPoint only engages date-axis
// layout when the CATEGORY numCache carries a
// date-looking formatCode — with "General" it renders
// an empty plot. Propagate the axis format into every
// series' cat numCache/numLit formatCode.
if (axNf is C.DateAxis && plotArea != null)
{
foreach (var catData in plotArea
.Descendants<C.CategoryAxisData>())
{
var cache = (OpenXmlCompositeElement?)catData
.GetFirstChild<C.NumberReference>()
?.GetFirstChild<C.NumberingCache>()
?? catData.GetFirstChild<C.NumberLiteral>();
var fc = cache?.GetFirstChild<C.FormatCode>();
if (fc != null) fc.Text = value;
}
}
}
directlyHandled.Add(key);
break;
}
case "basetimeunit":
{
// Date-axis base time unit round-trip (days/months/years).
if (targetAxis is C.DateAxis daxBtu)
{
var btuVal = value.Trim().ToLowerInvariant() switch
{
"days" or "day" => (C.TimeUnitValues?)C.TimeUnitValues.Days,
"months" or "month" => C.TimeUnitValues.Months,
"years" or "year" => C.TimeUnitValues.Years,
_ => null,
};
if (btuVal == null)
throw new ArgumentException($"Invalid baseTimeUnit '{value}': expected days, months, or years.");
daxBtu.RemoveAllChildren<C.BaseTimeUnit>();
var btuEl = new C.BaseTimeUnit { Val = btuVal.Value };
// CT_DateAx: …auto?, lblOffset?, baseTimeUnit?, majorUnit?…
var btuBefore = (OpenXmlElement?)daxBtu.GetFirstChild<C.MajorUnit>();
if (btuBefore != null) daxBtu.InsertBefore(btuEl, btuBefore);
else daxBtu.AppendChild(btuEl);
}
directlyHandled.Add(key);
break;
}
case "ticklabelpos":
case "ticklabelposition":
{
// CONSISTENCY(chart/axis-role-write): legacy SetChartProperties
// tickLabelPos sweeps every ValueAxis + CategoryAxis. Role-scoped
// write must only mutate the resolved axis. (R43-4)
if (targetAxis is OpenXmlCompositeElement axTlp)
{
var tlPos = value.ToLowerInvariant() switch
{
"none" => C.TickLabelPositionValues.None,
"high" or "top" => C.TickLabelPositionValues.High,
"low" or "bottom" => C.TickLabelPositionValues.Low,
_ => C.TickLabelPositionValues.NextTo
};
axTlp.RemoveAllChildren<C.TickLabelPosition>();
InsertAxisChildInOrder(axTlp, new C.TickLabelPosition { Val = tlPos });
}
directlyHandled.Add(key);
break;
}
case "labeloffset":
{
// Category-axis-only per OOXML schema. Skip on other roles.
if (normalizedRole != "category") { directlyHandled.Add(key); break; }
if (targetAxis is OpenXmlCompositeElement axLo)
{
axLo.RemoveAllChildren<C.LabelOffset>();
axLo.AppendChild(new C.LabelOffset { Val = (ushort)ParseHelpers.SafeParseInt(value, "labelOffset") });
}
directlyHandled.Add(key);
break;
}
case "ticklabelskip":
case "tickskip":
{
if (normalizedRole != "category") { directlyHandled.Add(key); break; }
if (targetAxis is OpenXmlCompositeElement axTls)
{
var tlsVal = ParseHelpers.SafeParseInt(value, "tickLabelSkip");
if (tlsVal < 1 || tlsVal > 65535)
throw new ArgumentException($"Invalid 'tickLabelSkip' value: '{value}'. Must be an integer 1..65535 (OOXML ST_Skip).");
axTls.RemoveAllChildren<C.TickLabelSkip>();
axTls.AppendChild(new C.TickLabelSkip { Val = tlsVal });
}
directlyHandled.Add(key);
break;
}
case "crossbetween":
{
// Schema: crossBetween only valid on value/value2; on category/series ignore.
if (normalizedRole is not ("value" or "value2")) { directlyHandled.Add(key); break; }
if (targetAxis is OpenXmlCompositeElement axCb)
{
axCb.RemoveAllChildren<C.CrossBetween>();
var cbVal = value.ToLowerInvariant() switch
{
"midcat" or "midpoint" => C.CrossBetweenValues.MidpointCategory,
_ => C.CrossBetweenValues.Between
};
// CT_ValAx schema: ..., crossAx, crosses?, crossesAt?,
// crossBetween?, majorUnit?, minorUnit?, dispUnits?, extLst?.
// AppendChild lands it after majorUnit which PowerPoint
// rejects ("unexpected child element 'crossBetween'").
var cb = new C.CrossBetween { Val = cbVal };
var cbAnchor = axCb.GetFirstChild<C.CrossesAt>() as OpenXmlElement
?? axCb.GetFirstChild<C.Crosses>() as OpenXmlElement
?? axCb.GetFirstChild<C.CrossingAxis>() as OpenXmlElement;
if (cbAnchor != null) cbAnchor.InsertAfterSelf(cb);
else axCb.AppendChild(cb);
}
directlyHandled.Add(key);
break;
}
case "axisorientation":
case "orientation":
case "axisreverse":
{
// Role-scoped orientation write — legacy `axisorientation` in
// SetChartProperties writes to the primary value axis only,
// ignoring role. Apply directly on the resolved axis. (R43-3)
if (targetAxis is OpenXmlCompositeElement axOr)
{
var scaling = axOr.GetFirstChild<C.Scaling>();
if (scaling != null)
{
scaling.RemoveAllChildren<C.Orientation>();
var orientVal = (ParseHelpers.IsValidBooleanString(value) && ParseHelpers.IsTruthy(value)) ||
value.Equals("maxmin", StringComparison.OrdinalIgnoreCase)
? C.OrientationValues.MaxMin : C.OrientationValues.MinMax;
scaling.PrependChild(new C.Orientation { Val = orientVal });
}
}
directlyHandled.Add(key);
break;
}
case "majorunit":
case "minorunit":
{
// Schema: majorUnit / minorUnit only valid on value/value2.
// Without this direct-apply branch the role-scoped Set on
// role=value2 falls through to the chart-level case which
// always grabs the primary ValueAxis, so the secondary
// axis silently retained its old tick interval.
if (normalizedRole is not ("value" or "value2"))
{
directlyHandled.Add(key);
break;
}
if (targetAxis is OpenXmlCompositeElement axMu)
{
var unit = ParseHelpers.SafeParseDouble(value, lower);
if (!(unit > 0))
throw new ArgumentException(
$"Invalid {lower} '{value}': must be a positive number (OOXML ST_AxisUnit > 0).");
if (lower == "majorunit")
{
axMu.RemoveAllChildren<C.MajorUnit>();
InsertValAxChildInOrder(axMu, new C.MajorUnit { Val = unit });
}
else
{
axMu.RemoveAllChildren<C.MinorUnit>();
InsertValAxChildInOrder(axMu, new C.MinorUnit { Val = unit });
}
}
directlyHandled.Add(key);
break;
}
case "majorgridlines":
case "minorgridlines":
{
if (targetAxis is OpenXmlCompositeElement axCe)
{
var enable = !value.Equals("none", StringComparison.OrdinalIgnoreCase)
&& !value.Equals("false", StringComparison.OrdinalIgnoreCase);
if (lower == "majorgridlines")
{
axCe.RemoveAllChildren<C.MajorGridlines>();
if (enable)
{
var gl = new C.MajorGridlines();
if (!value.Equals("true", StringComparison.OrdinalIgnoreCase))
gl.AppendChild(BuildLineShapeProperties(value));
axCe.InsertAfter(gl, axCe.GetFirstChild<C.AxisPosition>());
}
}
else
{
axCe.RemoveAllChildren<C.MinorGridlines>();
if (enable)
{
var gl = new C.MinorGridlines();
if (!value.Equals("true", StringComparison.OrdinalIgnoreCase))
gl.AppendChild(BuildLineShapeProperties(value));
var afterEl = (OpenXmlElement?)axCe.GetFirstChild<C.MajorGridlines>()
?? axCe.GetFirstChild<C.AxisPosition>();
if (afterEl != null) axCe.InsertAfter(gl, afterEl);
}
}
}
directlyHandled.Add(key);
break;
}
case "title.font" or "titlefont":
case "title.size" or "titlesize":
case "title.color" or "titlecolor":
case "title.bold" or "titlebold":
// CONSISTENCY(axis-title-styling): these used to fall to default
// and forward to the chart-level title handler, which then
// mutated the wrong title (or returned UNSUPPORTED when no
// chart title existed). Buffer them here and apply AFTER the
// translated forward — that's where `axisTitle=…` / `title=…`
// creates the C.Title on this axis, so we need to operate on
// the post-forward DOM.
pendingAxisTitleStyling[lower] = value;
directlyHandled.Add(key);
break;
default:
// Forward unknown keys verbatim; SetChartProperties will flag them as unsupported.
translated[key] = value;
break;
}
}
var unsupported = translated.Count > 0
? SetChartProperties(chartPart, translated)
: new List<string>();
// Apply axis-scoped title styling AFTER the chart-level setter has
// had a chance to create/replace the C.Title (axistitle / cattitle
// build a fresh title from scratch). Re-resolve targetAxis since the
// forward may have mutated the plotArea.
if (pendingAxisTitleStyling.Count > 0)
{
var axisAfter = plotArea != null ? FindAxisByRole(plotArea, normalizedRole) : null;
var axisTitle = (axisAfter as OpenXmlCompositeElement)?.GetFirstChild<C.Title>();
// R52 bt-2: when title.size / title.bold / title.font / title.color
// is set on an axis whose title element is missing, auto-create an
// empty <c:title> block on the resolved axis. Previously these keys
// were silently shoveled into the unsupported list — asymmetric with
// the Get side (which surfaces the same keys via DefaultRunProperties
// on a present-but-empty title) and indistinguishable from a true
// unknown-key reject. Mirrors BuildChartTitle's empty-title shape.
if (axisTitle == null && axisAfter is OpenXmlCompositeElement axHost)
{
axisTitle = new C.Title(
new C.ChartText(
new C.RichText(
new Drawing.BodyProperties(),
new Drawing.ListStyle(),
new Drawing.Paragraph(
new Drawing.ParagraphProperties(
new Drawing.DefaultRunProperties()),
new Drawing.Run(
new Drawing.RunProperties { Language = "en-US" },
new Drawing.Text(string.Empty))))),
new C.Overlay { Val = false });
// Schema order: ...title, numFmt, majorTickMark... — title sits
// after MinorGridlines / MajorGridlines / AxisPosition. Match
// the insertion site used by axistitle/cattitle in
// SetChartProperties so PowerPoint accepts the result.
var insertAfter = (OpenXmlElement?)axHost.GetFirstChild<C.MinorGridlines>()
?? (OpenXmlElement?)axHost.GetFirstChild<C.MajorGridlines>()
?? axHost.GetFirstChild<C.AxisPosition>();
if (insertAfter != null) axHost.InsertAfter(axisTitle, insertAfter);
else axHost.AppendChild(axisTitle);
}
foreach (var (axKey, axVal) in pendingAxisTitleStyling)
{
if (axisTitle == null) { unsupported.Add(axKey); continue; }
var norm = axKey.Replace("title.", "").Replace("title", "");
// R42-B2: title.font accepts either a bare font name ("Arial")
// or a composite "size:color:fontname" spec ("14:4472C4:Arial").
// The legacy path stored the entire composite as the LatinFont
// typeface, producing an invalid font with literal text
// "14:4472C4:Arial". Detect a `:`-delimited composite and route
// through BuildDefaultRunPropertiesFromCompoundSpec — identical
// parsing as the axisfont knob — to fan out size/color/font.
if (norm == "font" && axVal.Contains(':'))
{
var spec = BuildDefaultRunPropertiesFromCompoundSpec(axVal);
foreach (var run in axisTitle.Descendants<Drawing.Run>())
{
var rPr = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties());
// Font size: lift hundredths-of-a-point from the parsed defRp.
if (spec.FontSize?.HasValue == true)
rPr.FontSize = spec.FontSize.Value;
// Color: replace any existing SolidFill with the parsed one.
var fill = spec.GetFirstChild<Drawing.SolidFill>();
if (fill != null)
{
rPr.RemoveAllChildren<Drawing.SolidFill>();
DrawingEffectsHelper.InsertFillInRunProperties(rPr,
(Drawing.SolidFill)fill.CloneNode(true));
}
// Typeface: replace LatinFont + EastAsianFont.
var latin = spec.GetFirstChild<Drawing.LatinFont>();
if (latin != null)
{
rPr.RemoveAllChildren<Drawing.LatinFont>();
rPr.RemoveAllChildren<Drawing.EastAsianFont>();
rPr.AppendChild((Drawing.LatinFont)latin.CloneNode(true));
var ea = spec.GetFirstChild<Drawing.EastAsianFont>();
if (ea != null)
rPr.AppendChild((Drawing.EastAsianFont)ea.CloneNode(true));
}
}
// Mirror onto defRPr for fallback rendering.
var defRpComposite = axisTitle.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
if (defRpComposite != null)
{
if (spec.FontSize?.HasValue == true)
defRpComposite.FontSize = spec.FontSize.Value;
}
continue;
}
foreach (var run in axisTitle.Descendants<Drawing.Run>())
{
var rPr = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties());
switch (norm)
{
case "font":
rPr.RemoveAllChildren<Drawing.LatinFont>();
rPr.RemoveAllChildren<Drawing.EastAsianFont>();
rPr.AppendChild(new Drawing.LatinFont { Typeface = axVal });
rPr.AppendChild(new Drawing.EastAsianFont { Typeface = axVal });
break;
case "size":
var sizeStr = axVal.EndsWith("pt", System.StringComparison.OrdinalIgnoreCase)
? axVal[..^2] : axVal;
rPr.FontSize = (int)System.Math.Round(
ParseHelpers.SafeParseDouble(sizeStr, "title.size") * 100);
break;
case "color":
{
rPr.RemoveAllChildren<Drawing.SolidFill>();
var (rgb, _) = ParseHelpers.SanitizeColorForOoxml(axVal);
DrawingEffectsHelper.InsertFillInRunProperties(rPr,
new Drawing.SolidFill(new Drawing.RgbColorModelHex { Val = rgb }));
break;
}
case "bold":
rPr.Bold = ParseHelpers.IsTruthy(axVal);
break;
}
}
// Mirror chart-Setter behavior — keep defRPr in sync for size/bold.
var defRp = axisTitle.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
if (defRp != null)
{
switch (norm)
{
case "size":
var sizeStr = axVal.EndsWith("pt", System.StringComparison.OrdinalIgnoreCase)
? axVal[..^2] : axVal;
defRp.FontSize = (int)System.Math.Round(
ParseHelpers.SafeParseDouble(sizeStr, "title.size") * 100);
break;
case "bold":
defRp.Bold = ParseHelpers.IsTruthy(axVal);
break;
}
}
}
}
// directlyHandled keys are already applied; do not surface as unsupported.
return unsupported;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+729
View File
@@ -0,0 +1,729 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using C = DocumentFormat.OpenXml.Drawing.Charts;
namespace OfficeCli.Core;
/// <summary>
/// Shared chart build/read/set logic used by PPTX, Excel, and Word handlers.
/// All methods operate on ChartPart / C.Chart / C.PlotArea — independent of host document type.
/// </summary>
internal static partial class ChartHelper
{
// ==================== Parse Helpers ====================
internal static (string kind, bool is3D, bool stacked, bool percentStacked) ParseChartType(string chartType)
{
var ct = SchemaKeyNormalizer.Normalize(chartType);
var is3D = ct.EndsWith("3d") || ct.Contains("3d");
ct = ct.Replace("3d", "");
// OOXML has no Scatter3D or Radar3D variant — CT_ScatterChart and
// CT_RadarChart are 2D-only. Previously `scatter3d`/`radar3d` silently
// had the `3d` stripped and became plain scatter/radar, losing caller
// intent (round-trip returned `scatter`/`radar`, real PowerPoint
// rendered flat). Reject these forms explicitly with a helpful error
// pointing at the supported alternatives.
if (is3D && (ct == "scatter" || ct == "xy" || ct == "radar" || ct == "spider"))
{
throw new ArgumentException(
$"Chart type '{chartType}' is not supported. " +
"OOXML has no Scatter3D or Radar3D variant — both CT_ScatterChart and CT_RadarChart " +
"are 2D-only. Use 'scatter' / 'radar' (optionally with radarStyle=filled|marker|standard).");
}
var stacked = ct.Contains("stacked") && !ct.Contains("percent");
var percentStacked = ct.Contains("percentstacked") || ct.Contains("pstacked");
ct = ct.Replace("percentstacked", "").Replace("pstacked", "").Replace("stacked", "");
var kind = ct switch
{
"bar" => "bar",
"column" or "col" => "column",
"line" => "line",
"pie" => "pie",
"pieofpie" => "pieofpie",
"barofpie" => "barofpie",
"doughnut" or "donut" => "doughnut",
"area" => "area",
"scatter" or "xy" => "scatter",
"bubble" => "bubble",
"radar" or "spider" => "radar",
"stock" or "ohlc" => "stock",
"combo" => "combo",
"waterfall" or "wf" => "waterfall",
_ => throw new ArgumentException(
$"Unknown chart type: '{chartType}'. Supported types: " +
"column, bar, line, pie, doughnut, area, scatter, bubble, radar, stock, combo, waterfall, " +
"funnel, treemap, sunburst, boxWhisker, histogram, pareto. " +
"Modifiers: 3d (e.g. column3d), stacked (e.g. stackedColumn), percentStacked (e.g. percentStackedBar).")
};
return (kind, is3D, stacked, percentStacked);
}
/// <summary>
/// Extended series info that may contain cell references instead of literal data.
/// </summary>
internal class SeriesInfo
{
public string Name { get; set; } = "";
public double[]? Values { get; set; }
public string? ValuesRef { get; set; } // e.g. "Sheet1!$B$2:$B$13"
public string? CategoriesRef { get; set; } // e.g. "Sheet1!$A$2:$A$13"
// R52 bt-3: bubble charts store per-point sizes in a third series
// dimension. The literal data round-trips via Format["bubbleSize"];
// BubbleSizeRef carries the external cell range so dump→replay
// preserves the source <c:numRef> instead of falling back to the
// BuildBubbleChart default (size = y-values).
public double[]? BubbleSizeValues { get; set; }
public string? BubbleSizeRef { get; set; } // e.g. "[Book1.xlsx]Sheet1!$D$1:$D$3"
}
/// <summary>
/// Returns true if the value looks like a cell range reference (contains '!' or matches A1:B2 pattern).
/// </summary>
internal static bool IsRangeReference(string value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
if (value.Contains('!')) return true;
// Match patterns like A1:B13, $A$1:$B$13, AA1:ZZ999
return System.Text.RegularExpressions.Regex.IsMatch(value.Trim(),
@"^\$?[A-Za-z]+\$?\d+:\$?[A-Za-z]+\$?\d+$");
}
/// <summary>
/// Returns true if the value looks like a single cell reference with an
/// explicit sheet prefix (Sheet1!A1, Sheet1!$A$1, 'My Sheet'!A1:A1). Used
/// to detect when a series.name / chart title parameter should be emitted
/// as a c:strRef instead of literal c:v.
///
/// Bare cell-shaped tokens (e.g. "Q1", "A1", "B2") are deliberately NOT
/// treated as cell references — they collide with common literal labels
/// (quarter codes, product names) and emitting a strRef without an
/// external workbook backing causes real PowerPoint to render no title /
/// no series name at all (data loss). Callers wanting a cell reference
/// must qualify with the sheet name.
/// </summary>
internal static bool IsCellReference(string value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
var trimmed = value.Trim();
// Mandatory sheet prefix (Sheet1! or 'Sheet with spaces'!), single
// cell A1 or $A$1, optionally followed by :A1 range of size 1.
return System.Text.RegularExpressions.Regex.IsMatch(trimmed,
@"^(?:'[^']+'!|[A-Za-z_][\w\.]*!)\$?[A-Za-z]+\$?\d+(?::\$?[A-Za-z]+\$?\d+)?$");
}
/// <summary>
/// Normalizes a single-cell reference for use inside a chart's c:strRef/c:f.
/// Ensures absolute ($col$row) form and preserves any sheet prefix. If the
/// input is a A1:A1 style single-cell range, the range form is kept so the
/// output matches what Excel writes when a user points the Name field at a
/// single cell via the dialog.
/// </summary>
internal static string NormalizeCellReference(string value)
{
var trimmed = value.Trim();
string sheetPart = "";
string cellPart = trimmed;
var bangIdx = trimmed.IndexOf('!');
if (bangIdx >= 0)
{
sheetPart = trimmed[..(bangIdx + 1)];
cellPart = trimmed[(bangIdx + 1)..];
}
var parts = cellPart.Split(':');
for (int i = 0; i < parts.Length; i++)
parts[i] = AddAbsoluteMarkers(parts[i]);
return sheetPart + string.Join(":", parts);
}
/// <summary>
/// Normalizes a range reference by adding $ signs for absolute references.
/// If no sheet prefix, prepends defaultSheet.
/// </summary>
internal static string NormalizeRangeReference(string value, string? defaultSheet = null)
{
var trimmed = value.Trim();
string sheetPart = "";
string rangePart = trimmed;
var bangIdx = trimmed.IndexOf('!');
if (bangIdx >= 0)
{
sheetPart = trimmed[..(bangIdx + 1)];
rangePart = trimmed[(bangIdx + 1)..];
}
else if (!string.IsNullOrEmpty(defaultSheet))
{
sheetPart = defaultSheet + "!";
}
// Add $ signs to cell refs if not already present
var parts = rangePart.Split(':');
for (int i = 0; i < parts.Length; i++)
parts[i] = AddAbsoluteMarkers(parts[i]);
return sheetPart + string.Join(":", parts);
}
private static string AddAbsoluteMarkers(string cellRef)
{
// Already has $ signs — return as-is
if (cellRef.Contains('$')) return cellRef;
// Split into column letters and row digits
int firstDigit = 0;
for (int i = 0; i < cellRef.Length; i++)
{
if (char.IsDigit(cellRef[i])) { firstDigit = i; break; }
}
if (firstDigit == 0) return cellRef; // no digits found
var col = cellRef[..firstDigit];
var row = cellRef[firstDigit..];
return $"${col}${row}";
}
/// <summary>
/// Parse series data supporting both legacy format and new dotted syntax with cell references.
/// Dotted syntax: series1.name=Sales, series1.values=Sheet1!B2:B13, series1.categories=Sheet1!A2:A13
/// Legacy: series1=Sales:10,20,30 or data=Sales:10,20,30;Cost:5,8,12
/// </summary>
internal static List<(string name, double[] values)> ParseSeriesData(Dictionary<string, string> properties)
{
// CONSISTENCY(chart-series-name-alias): `series{N}Name=` flat form
// is a natural alias for the dotted `series{N}.name=`. Rewrite so
// both the dotted and legacy branches below see the canonical
// `series{N}.name` key. TryGetValue on the original `series{N}Name`
// key still fires (preserved tracking) — we add the dotted alias.
NormalizeFlatSeriesNameAliases(properties);
// Check for dotted syntax first
var extSeries = ParseSeriesDataExtended(properties);
if (extSeries != null && extSeries.Count > 0 && extSeries.Any(s => s.ValuesRef != null || s.CategoriesRef != null))
{
// Dotted syntax with references. Literal values may still ride
// alongside via `data=` / `series{N}=` (the batch emitter dumps
// both the cached point values AND the source cell-range ref) —
// merge them positionally so the built numLit carries the real
// data and the numRef rewrite preserves it as numCache. Without
// the merge a ref-bearing series came back with an EMPTY value
// list → numRef with no numCache → real PowerPoint silently
// renders a blank chart.
var literalSeries = ParseLiteralSeriesData(properties);
var merged = new List<(string name, double[] values)>(extSeries.Count);
for (int i = 0; i < extSeries.Count; i++)
{
var s = extSeries[i];
var name = s.Name;
var vals = s.Values ?? Array.Empty<double>();
if (i < literalSeries.Count)
{
if (vals.Length == 0) vals = literalSeries[i].values;
if (!properties.ContainsKey($"series{i + 1}.name")
&& literalSeries[i].name.Length > 0)
name = literalSeries[i].name;
}
merged.Add((name, vals));
}
for (int i = extSeries.Count; i < literalSeries.Count; i++)
merged.Add(literalSeries[i]);
return merged;
}
return ParseLiteralSeriesData(properties);
}
/// <summary>
/// Parse literal series data from `data=Name1:v1,v2;...` or the legacy
/// `series{N}=Name:v1,v2` / dotted `series{N}.values=v1,v2` keys.
/// </summary>
private static List<(string name, double[] values)> ParseLiteralSeriesData(Dictionary<string, string> properties)
{
var result = new List<(string name, double[] values)>();
if (properties.TryGetValue("data", out var dataStr))
{
// Determine series delimiter: use ';' if present, otherwise detect
// comma-separated name:value pairs (e.g. "Q1:40,Q2:55,Q3:70")
string[] seriesParts;
if (dataStr.Contains(';'))
{
seriesParts = dataStr.Split(';', StringSplitOptions.RemoveEmptyEntries);
}
else
{
// Check if comma-separated parts each contain a colon (name:value pairs)
var commaParts = dataStr.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (commaParts.Length > 1 && commaParts.All(p => p.Contains(':')))
seriesParts = commaParts;
else
seriesParts = new[] { dataStr };
}
foreach (var seriesPart in seriesParts)
{
// Split on the LAST colon: the value list is colon-free, so the
// rightmost colon separates a (possibly colon-containing) series
// name from its values, e.g. "Persons (Data year: 2021):1,2,3".
var colonIdx = seriesPart.LastIndexOf(':');
if (colonIdx < 0) continue;
var name = seriesPart[..colonIdx].Trim();
var valStr = seriesPart[(colonIdx + 1)..].Trim();
if (string.IsNullOrEmpty(valStr))
throw new ArgumentException($"Series '{name}' has no data values. Expected format: 'Name:1,2,3'");
var vals = ParseSeriesValues(valStr, name);
result.Add((name, vals));
}
return result;
}
for (int i = 1; i <= 20; i++)
{
// Read both keys up front so TrackingPropertyDictionary marks each
// consumed regardless of which branch supplies the values. Without
// this, `series{i}=` combined with `series{i}.name=` fell through
// to UNSUPPORTED + silent value drop (interview-edit R4 major).
var hasDotName = properties.TryGetValue($"series{i}.name", out var dotName);
var hasDotValues = properties.TryGetValue($"series{i}.values", out var dotValues);
var hasLegacy = properties.TryGetValue($"series{i}", out var legacyStr);
// Check for dotted syntax first: series1.name, series1.values
if (hasDotName || hasDotValues)
{
var name = dotName ?? $"Series {i}";
// CONSISTENCY(chart-series-mixed): when dotted .name is given
// without dotted .values, fall back to the legacy `series{i}=`
// key as the values source rather than silently dropping it.
var valuesStr = !string.IsNullOrEmpty(dotValues) ? dotValues
: (hasLegacy ? legacyStr : "");
if (!string.IsNullOrEmpty(valuesStr) && !IsRangeReference(valuesStr))
{
var vals = ParseSeriesValues(valuesStr, name);
result.Add((name, vals));
}
else
{
// Reference-based — add empty placeholder (actual ref handled by BuildChartSpace)
result.Add((name, Array.Empty<double>()));
}
continue;
}
// Legacy format: series1=Sales:10,20,30
if (!hasLegacy) continue;
var seriesStr = legacyStr!;
// CONSISTENCY(chart-series-rangeref): mirror the dotted-syntax
// guard above (line 253) — if the legacy value is a range
// reference (e.g. "Sheet1!B2:B7"), don't try to parse it as
// literal comma-separated numbers. ApplySeriesReferences picks
// it up later via the series{N} key. Without this guard
// ParseSeriesValues throws "Invalid data value 'B7'".
if (IsRangeReference(seriesStr))
{
result.Add(($"Series {i}", Array.Empty<double>()));
continue;
}
// Split on the LAST colon: values are colon-free comma numbers, so
// the rightmost colon separates a (possibly colon-containing) series
// name from its values (e.g. "Persons (Data year: 2021):1,2,3").
var colonIdx = seriesStr.LastIndexOf(':');
if (colonIdx < 0)
{
var vals = ParseSeriesValues(seriesStr, $"series{i}");
result.Add(($"Series {i}", vals));
}
else
{
var name = seriesStr[..colonIdx].Trim();
var vals = ParseSeriesValues(seriesStr[(colonIdx + 1)..], name);
result.Add((name, vals));
}
}
return result;
}
/// <summary>
/// Parse extended series data with cell references support.
/// Returns null if no dotted syntax series found.
/// </summary>
internal static List<SeriesInfo>? ParseSeriesDataExtended(Dictionary<string, string> properties)
{
// Same flat-name alias rewrite as ParseSeriesData entry; idempotent
// when called second time.
NormalizeFlatSeriesNameAliases(properties);
var result = new List<SeriesInfo>();
for (int i = 1; i <= 20; i++)
{
var hasName = properties.TryGetValue($"series{i}.name", out var nameStr);
var hasValues = properties.TryGetValue($"series{i}.values", out var valuesStr);
// `series{N}.valuesRef=<range>` carries the source cell-range ref
// WITHOUT displacing literal values (unlike `.values=<range>`).
// The batch emitter dumps it from the Reader's per-series
// Format["valuesRef"] so dump→replay rebuilds numRef + numCache.
var hasValuesRef = properties.TryGetValue($"series{i}.valuesRef", out var valuesRefStr);
var hasCats = properties.TryGetValue($"series{i}.categories", out var catsStr);
var hasBubbleSize = properties.TryGetValue($"series{i}.bubbleSize", out var bsStr);
var hasBubbleSizeRef = properties.ContainsKey($"series{i}.bubbleSizeRef");
// CONSISTENCY(chart-series-rangeref): legacy `series{N}=Sheet1!B2:B3`
// range-ref form mirrors `series{N}.values=<range>`. ParseSeriesData
// emits an empty literal placeholder for it (line 289); pick the range
// up here as a ValuesRef so the series gets a numRef, not a blank val.
string? legacyRangeRef = null;
if (!hasValues
&& properties.TryGetValue($"series{i}", out var legacyStr)
&& !string.IsNullOrEmpty(legacyStr)
&& IsRangeReference(legacyStr))
{
legacyRangeRef = legacyStr;
}
if (!hasName && !hasValues && !hasValuesRef && !hasCats && !hasBubbleSize && !hasBubbleSizeRef && legacyRangeRef == null) continue;
var info = new SeriesInfo { Name = nameStr ?? $"Series {i}" };
if (!string.IsNullOrEmpty(valuesStr))
{
if (IsRangeReference(valuesStr))
info.ValuesRef = NormalizeRangeReference(valuesStr);
else
info.Values = ParseSeriesValues(valuesStr, info.Name);
}
else if (legacyRangeRef != null)
{
info.ValuesRef = NormalizeRangeReference(legacyRangeRef);
}
if (info.ValuesRef == null && !string.IsNullOrEmpty(valuesRefStr)
&& IsRangeReference(valuesRefStr))
{
info.ValuesRef = NormalizeRangeReference(valuesRefStr);
}
if (!string.IsNullOrEmpty(catsStr))
{
if (IsRangeReference(catsStr))
info.CategoriesRef = NormalizeRangeReference(catsStr);
}
// R52 bt-3: bubble series carry per-point sizes. `series{N}.bubbleSize`
// accepts either a comma-separated literal list or a cell range; the
// range form preserves the source <c:numRef> so PowerPoint shows the
// correct bubble pixel-geometry from the linked workbook data.
if (!string.IsNullOrEmpty(bsStr))
{
if (IsRangeReference(bsStr))
info.BubbleSizeRef = NormalizeRangeReference(bsStr);
else
info.BubbleSizeValues = ParseSeriesValues(bsStr, info.Name + ".bubbleSize");
}
// `series{N}.bubbleSizeRef` is the explicit ref key emitted by the
// batch emitter when a source <c:bubbleSize><c:numRef> is detected.
// It coexists with the literal `series{N}.bubbleSize=cached,values`
// — the ref takes precedence, the literal becomes the numCache.
if (properties.TryGetValue($"series{i}.bubbleSizeRef", out var bsRefStr)
&& !string.IsNullOrEmpty(bsRefStr))
{
info.BubbleSizeRef = NormalizeRangeReference(bsRefStr);
}
result.Add(info);
}
return result.Count > 0 ? result : null;
}
/// <summary>
/// Parse the top-level categories property, supporting both literal and reference values.
/// Returns the reference string if it's a range reference, null otherwise (literal handled separately).
/// </summary>
internal static string? ParseCategoriesRef(Dictionary<string, string> properties)
{
// Explicit `categoriesRef=<range>` (the Reader/batch-emitter form:
// literal labels in `categories=` for the strCache, the source cell
// range here) wins over range-form `categories=`.
if (properties.TryGetValue("categoriesRef", out var catRefStr)
&& IsRangeReference(catRefStr))
return NormalizeRangeReference(catRefStr);
if (!properties.TryGetValue("categories", out var catStr)) return null;
if (IsRangeReference(catStr)) return NormalizeRangeReference(catStr);
return null;
}
// CONSISTENCY(chart-series-name-alias): `series{N}Name=Revenue` flat form
// → `series{N}.name=Revenue` dotted form. Records the read on the
// original flat key (via TryGetValue) so handler-as-truth tracking still
// marks the user input consumed.
private static void NormalizeFlatSeriesNameAliases(Dictionary<string, string> properties)
{
for (int i = 1; i <= 20; i++)
{
var flatKey = $"series{i}Name";
if (properties.TryGetValue(flatKey, out var nameVal))
{
var dottedKey = $"series{i}.name";
if (!properties.ContainsKey(dottedKey))
properties[dottedKey] = nameVal;
// Remove the flat key so SetChartProperties' dispatch loop
// doesn't also iterate it — the legacy `series{N}=` branch
// does int.TryParse on the suffix ("2Name") and reports it
// as unsupported. TryGetValue above already marked the
// original input consumed for handler-as-truth tracking.
properties.Remove(flatKey);
}
}
}
private static double[] ParseSeriesValues(string valStr, string seriesName)
{
return valStr.Split(',').Select(v =>
{
var trimmed = v.Trim();
if (!double.TryParse(trimmed, System.Globalization.CultureInfo.InvariantCulture, out var num)
|| double.IsNaN(num) || double.IsInfinity(num))
throw new ArgumentException($"Invalid data value '{trimmed}' in series '{seriesName}'. Expected comma-separated finite numbers (e.g. '1,2,3').");
return num;
}).ToArray();
}
internal static string[]? ParseCategories(Dictionary<string, string> properties)
{
if (!properties.TryGetValue("categories", out var catStr)) return null;
// If the value is a cell range reference, don't treat as literal categories
if (IsRangeReference(catStr)) return null;
return catStr.Split(',').Select(c => c.Trim()).ToArray();
}
// BUG-DUMP-R36-3: series indices (0-based) whose dump carried per-point
// <c:dPt> styling (and/or a verbatim series <c:spPr>) but NO series-level
// fill key (series{N}.color / .gradient / .spPr). For these the source had
// no series-level <c:spPr>; the chart builder must therefore NOT inject the
// Office accent1 default — doing so plants a spurious solidFill that a
// partial-dPt series would visibly show. Mirrors the spec "only emit a
// series spPr when one was actually captured". Fresh `add chart` (no dPt
// keys) is unaffected and keeps its default palette.
internal static HashSet<int> SeriesWithNoCapturedFill(Dictionary<string, string> properties)
{
var result = new HashSet<int>();
foreach (var kv in properties)
{
var k = kv.Key;
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
int dot = k.IndexOf('.');
if (dot <= 6) continue;
var mid = k.Substring(6, dot - 6);
if (!int.TryParse(mid, out var idx1) || idx1 < 1) continue;
var sub = k.Substring(dot + 1);
// A series carrying verbatim per-point styling (dPt) or a verbatim
// series spPr is the replay signal. We only flag it for default
// suppression when it does NOT also carry a series-level fill key.
if (!sub.Equals("dPt", StringComparison.OrdinalIgnoreCase)
&& !sub.Equals("spPr", StringComparison.OrdinalIgnoreCase)
&& !sub.Equals("inheritFill", StringComparison.OrdinalIgnoreCase)) continue;
// `series{N}.inheritFill=true`: the dump saw a source series with
// no <c:spPr> at all — the fill is theme-inherited, so the replay
// must not pin a DefaultSeriesColors palette entry over it. The
// TryGetValue registers the key as consumed (handler-as-truth
// tracking doesn't fire on Dictionary-typed foreach iteration).
if (sub.Equals("inheritFill", StringComparison.OrdinalIgnoreCase))
properties.TryGetValue(k, out _);
int idx0 = idx1 - 1;
// A series{N}.spPr key IS a captured series fill — keep it (the
// verbatim spPr is applied post-build), so do not flag spPr-bearing
// series. Only dPt-only series (no series-level fill) get flagged.
if (sub.Equals("spPr", StringComparison.OrdinalIgnoreCase)) continue;
bool hasSeriesFill =
properties.ContainsKey($"series{idx1}.color")
|| properties.ContainsKey($"series{idx1}.gradient")
|| properties.ContainsKey($"series{idx1}.spPr");
if (!hasSeriesFill) result.Add(idx0);
}
return result;
}
internal static string[]? ParseSeriesColors(Dictionary<string, string> properties)
{
// CONSISTENCY(chart-series-color): Add path accepts both the
// compact `colors=red,blue,green` form and per-series dotted
// `series{N}.color=<hex>` keys (same vocabulary that `set chart`
// already supports via ApplySeriesColor). When both are supplied,
// dotted keys override positions in the `colors` array.
string[]? arr = null;
if (properties.TryGetValue("colors", out var colorsStr) || properties.TryGetValue("seriesColors", out colorsStr))
arr = colorsStr.Split(',').Select(c => c.Trim()).ToArray();
// Collect per-series dotted color keys
var dotted = new Dictionary<int, string>();
foreach (var kv in properties)
{
var k = kv.Key;
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
if (!k.EndsWith(".color", StringComparison.OrdinalIgnoreCase)) continue;
var mid = k.Substring(6, k.Length - 6 - ".color".Length);
if (!int.TryParse(mid, out var idx) || idx < 1) continue;
// Mark this series{N}.color key consumed. A `foreach` over a
// Dictionary<>-typed parameter binds to the base struct enumerator,
// which bypasses TrackingPropertyDictionary's consumption tracking
// (only TryGetValue/ContainsKey/indexer route through the recording
// comparer). Without this ContainsKey, the Add path reports an
// applied series color as UNSUPPORTED and exits non-zero.
_ = properties.ContainsKey(k);
if (!string.IsNullOrWhiteSpace(kv.Value))
dotted[idx] = kv.Value.Trim();
}
if (dotted.Count == 0) return arr;
var maxIdx = dotted.Keys.Max();
var size = Math.Max(maxIdx, arr?.Length ?? 0);
var merged = new string[size];
for (int i = 0; i < size; i++)
{
if (dotted.TryGetValue(i + 1, out var c))
merged[i] = c;
else if (arr != null && i < arr.Length && !string.IsNullOrEmpty(arr[i]))
merged[i] = arr[i];
else
merged[i] = DefaultSeriesColors[i % DefaultSeriesColors.Length];
}
return merged;
}
/// <summary>
/// Like ParseSeriesColors but returns ONLY the per-series dotted color
/// keys (`series{N}.color=<hex>`), without merging the positional
/// `colors=` array. Used by single-series chart builders
/// (pie/doughnut/stock) where positional `colors=` is per-data-point but
/// `series{N}.color` should still tint the whole series.
/// </summary>
internal static Dictionary<int, string> ParseDottedSeriesColorsOnly(Dictionary<string, string> properties)
{
var dotted = new Dictionary<int, string>();
foreach (var kv in properties)
{
var k = kv.Key;
if (!k.StartsWith("series", StringComparison.OrdinalIgnoreCase)) continue;
if (!k.EndsWith(".color", StringComparison.OrdinalIgnoreCase)) continue;
var mid = k.Substring(6, k.Length - 6 - ".color".Length);
if (!int.TryParse(mid, out var idx) || idx < 1) continue;
// Mark consumed — see ParseSeriesColors: foreach over a
// Dictionary<>-typed param bypasses the tracking enumerator.
_ = properties.ContainsKey(k);
if (!string.IsNullOrWhiteSpace(kv.Value))
dotted[idx] = kv.Value.Trim();
}
return dotted;
}
/// <summary>
/// Positional-only `colors=` / `seriesColors=` array, returned unmerged.
/// Used by pie / doughnut / pieofpie / barofpie / stock builders where the
/// positional list maps to per-data-point dPt overrides, but `series{N}.
/// color` MUST NOT bleed into the dPt list (it routes through
/// ApplyDottedSeriesColors as a series-level fill instead). The merged
/// array from ParseSeriesColors conflates the two, so a doughnut with
/// only `series1.color=#C00000` emitted a spurious dPt#0 with the same
/// fill — Get readback then surfaced both `series1.color` AND
/// `point1.color`, breaking dump→replay byte-equality.
/// </summary>
internal static string[]? ParsePositionalColorsOnly(Dictionary<string, string> properties)
{
if (properties.TryGetValue("colors", out var colorsStr)
|| properties.TryGetValue("seriesColors", out colorsStr))
return colorsStr.Split(',').Select(c => c.Trim()).ToArray();
return null;
}
// ==================== ManualLayout Helpers ====================
/// <summary>
/// Ensures the given element has a Layout > ManualLayout child and sets the specified
/// positional property (x/y/w/h). Creates Layout and ManualLayout if missing.
/// For plotArea, LayoutTarget is set to Inner; for others it is omitted.
/// </summary>
internal static void SetManualLayoutProperty(OpenXmlCompositeElement parent, string prop, double value, bool isPlotArea = false)
{
var layout = parent.GetFirstChild<C.Layout>();
if (layout == null)
{
layout = new C.Layout();
// Insert layout after structural elements to respect schema order.
// c:title → tx, [layout], overlay, ...
// c:legend → legendPos, legendEntry*, [layout], overlay, ...
// c:dLbl → idx, delete, [layout], ...
// c:plotArea → layout is first child (InsertAt 0 is correct)
if (isPlotArea)
{
parent.InsertAt(layout, 0);
}
else if (parent is C.DataLabel)
{
// CT_DLbl: idx, delete, [layout], tx, numFmt, spPr, ...
var insertAfter = parent.GetFirstChild<C.Delete>() as OpenXmlElement
?? parent.GetFirstChild<C.Index>() as OpenXmlElement;
if (insertAfter != null)
insertAfter.InsertAfterSelf(layout);
else
parent.InsertAt(layout, 0);
}
else
{
// c:title → tx, [layout], overlay, ...
// c:legend → legendPos, legendEntry*, [layout], overlay, ...
var insertAfter = parent.GetFirstChild<C.ChartText>() as OpenXmlElement
?? parent.ChildElements.LastOrDefault(
e => e.LocalName is "legendPos" or "legendEntry") as OpenXmlElement;
if (insertAfter != null)
insertAfter.InsertAfterSelf(layout);
else
parent.InsertAt(layout, 0);
}
}
var ml = layout.GetFirstChild<C.ManualLayout>();
if (ml == null)
{
ml = new C.ManualLayout();
if (isPlotArea)
ml.LayoutTarget = new C.LayoutTarget { Val = C.LayoutTargetValues.Inner };
ml.LeftMode = new C.LeftMode { Val = C.LayoutModeValues.Edge };
ml.TopMode = new C.TopMode { Val = C.LayoutModeValues.Edge };
layout.AppendChild(ml);
}
// Use typed properties to guarantee schema order (OneSequence)
switch (prop)
{
case "x": ml.Left = new C.Left { Val = value }; break;
case "y": ml.Top = new C.Top { Val = value }; break;
case "w": ml.Width = new C.Width { Val = value }; break;
case "h": ml.Height = new C.Height { Val = value }; break;
}
}
/// <summary>
/// Read ManualLayout x/y/w/h from an element that has Layout as a child.
/// Writes results into node.Format with the given prefix (e.g. "plotArea", "title", "legend").
/// </summary>
internal static void ReadManualLayout(OpenXmlCompositeElement parent, DocumentNode node, string prefix)
{
var layout = parent.GetFirstChild<C.Layout>();
var ml = layout?.GetFirstChild<C.ManualLayout>();
if (ml == null) return;
var x = ml.Left?.Val?.Value;
var y = ml.Top?.Val?.Value;
var w = ml.Width?.Val?.Value;
var h = ml.Height?.Val?.Value;
if (x != null) node.Format[$"{prefix}.x"] = x.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
if (y != null) node.Format[$"{prefix}.y"] = y.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
if (w != null) node.Format[$"{prefix}.w"] = w.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
if (h != null) node.Format[$"{prefix}.h"] = h.Value.ToString("0.######", System.Globalization.CultureInfo.InvariantCulture);
}
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Chart style presets — curated property combinations for professional chart styling.
/// Applied via Set(chart, { ["preset"] = "minimal" }).
/// </summary>
internal static class ChartPresets
{
internal static Dictionary<string, string>? GetPreset(string presetName)
{
return presetName.ToLowerInvariant() switch
{
"minimal" => Minimal,
"dark" => Dark,
"corporate" => Corporate,
"magazine" => Magazine,
"dashboard" => Dashboard,
"colorful" => Colorful,
"monochrome" or "mono" => Monochrome,
_ => null
};
}
internal static readonly string[] PresetNames =
{
"minimal", "dark", "corporate", "magazine", "dashboard", "colorful", "monochrome"
};
/// <summary>
/// Minimal: clean, light, emphasis on data. Thin gray gridlines, no borders, small labels.
/// </summary>
private static readonly Dictionary<string, string> Minimal = new()
{
["gridlines"] = "E0E0E0:0.3",
["minorGridlines"] = "none",
["plotArea.border"] = "none",
["chartArea.border"] = "none",
["axisLine"] = "none",
["majorTickMark"] = "none",
["minorTickMark"] = "none",
["tickLabelPos"] = "nextTo",
["axisFont"] = "9:808080",
["legend"] = "bottom",
["legendFont"] = "9:808080",
["plotFill"] = "none",
["chartFill"] = "none",
["roundedCorners"] = "false",
["colors"] = "4472C4,ED7D31,A5A5A5,FFC000,5B9BD5,70AD47",
};
/// <summary>
/// Dark: dark background, bright data, white text. Suitable for presentations on dark slides.
/// </summary>
private static readonly Dictionary<string, string> Dark = new()
{
["chartFill"] = "1E1E1E",
["plotFill"] = "2D2D2D",
["gridlines"] = "404040:0.3",
["minorGridlines"] = "none",
["axisLine"] = "555555:0.5",
["majorTickMark"] = "none",
["axisFont"] = "9:AAAAAA",
["legendFont"] = "9:CCCCCC",
["legend"] = "bottom",
["title.color"] = "FFFFFF",
["title.size"] = "16",
["plotArea.border"] = "none",
["chartArea.border"] = "444444:0.5",
["roundedCorners"] = "true",
["colors"] = "5B9BD5,FF6B6B,51CF66,FCC419,CC5DE8,22B8CF",
};
/// <summary>
/// Corporate: professional blue-gray palette, clean axes, suitable for business reports.
/// </summary>
private static readonly Dictionary<string, string> Corporate = new()
{
["gridlines"] = "D6DCE4:0.4",
["minorGridlines"] = "none",
["axisLine"] = "8B949E:0.5",
["majorTickMark"] = "out",
["minorTickMark"] = "none",
["axisFont"] = "10:44546A",
["legendFont"] = "10:44546A",
["legend"] = "right",
["title.bold"] = "true",
["title.size"] = "14",
["title.color"] = "44546A",
["plotFill"] = "none",
["chartFill"] = "none",
["plotArea.border"] = "D6DCE4:0.3",
["chartArea.border"] = "none",
["roundedCorners"] = "false",
["colors"] = "2E75B6,44546A,4472C4,A5A5A5,5B9BD5,264478",
};
/// <summary>
/// Magazine: bold, large title, no axes, direct data labels. Storytelling style.
/// </summary>
private static readonly Dictionary<string, string> Magazine = new()
{
["gridlines"] = "none",
["minorGridlines"] = "none",
["axisVisible"] = "false",
["axisLine"] = "none",
["majorTickMark"] = "none",
["title.bold"] = "true",
["title.size"] = "20",
["title.color"] = "333333",
["plotFill"] = "none",
["chartFill"] = "none",
["plotArea.border"] = "none",
["chartArea.border"] = "none",
["legend"] = "none",
["datalabels"] = "value",
["labelPos"] = "outsideEnd",
["labelfont"] = "11:555555",
["roundedCorners"] = "false",
["colors"] = "E15759,4E79A7,F28E2B,76B7B2,59A14F,EDC948",
};
/// <summary>
/// Dashboard: compact, dense information, thin gridlines, small fonts.
/// </summary>
private static readonly Dictionary<string, string> Dashboard = new()
{
["gridlines"] = "EEEEEE:0.2",
["minorGridlines"] = "none",
["axisLine"] = "CCCCCC:0.3",
["majorTickMark"] = "none",
["axisFont"] = "8:999999",
["legendFont"] = "8:999999",
["legend"] = "bottom",
["title.size"] = "11",
["title.bold"] = "true",
["title.color"] = "555555",
["plotFill"] = "none",
["chartFill"] = "none",
["plotArea.border"] = "none",
["chartArea.border"] = "E0E0E0:0.3",
["roundedCorners"] = "true",
["gapWidth"] = "80",
["colors"] = "4CAF50,2196F3,FF9800,9C27B0,F44336,00BCD4",
};
/// <summary>
/// Colorful: vibrant, saturated colors with moderate styling. Fun and engaging.
/// </summary>
private static readonly Dictionary<string, string> Colorful = new()
{
["gridlines"] = "E8E8E8:0.3",
["minorGridlines"] = "none",
["axisLine"] = "none",
["majorTickMark"] = "none",
["axisFont"] = "10:666666",
["legendFont"] = "10:444444",
["legend"] = "bottom",
["plotFill"] = "none",
["chartFill"] = "none",
["plotArea.border"] = "none",
["chartArea.border"] = "none",
["roundedCorners"] = "true",
["colors"] = "FF6384,36A2EB,FFCE56,4BC0C0,9966FF,FF9F40,C9CBCF,7BC8A4",
};
/// <summary>
/// Monochrome: single-hue progression, elegant and accessible.
/// </summary>
private static readonly Dictionary<string, string> Monochrome = new()
{
["gridlines"] = "E0E0E0:0.3",
["minorGridlines"] = "none",
["axisLine"] = "999999:0.4",
["majorTickMark"] = "out",
["axisFont"] = "9:666666",
["legendFont"] = "9:666666",
["legend"] = "bottom",
["plotFill"] = "none",
["chartFill"] = "none",
["plotArea.border"] = "none",
["chartArea.border"] = "none",
["roundedCorners"] = "false",
["colors"] = "1A3A5C,2E6B8A,4A9BBF,7BC0E0,B0D9EF,D6EBF5",
};
}
@@ -0,0 +1,798 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Text;
using DocumentFormat.OpenXml;
using Drawing = DocumentFormat.OpenXml.Drawing;
using CX = DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
namespace OfficeCli.Core;
/// <summary>
/// Extract ChartInfo from a cx:chart (Office 2016 extended chart) element and
/// emit SVG for the shape primitives that don't map onto the regular cChart
/// renderers (treemap nested rectangles, sunburst arcs, box-whisker boxes).
///
/// Histogram and funnel reuse the existing RenderBarChartSvg pipeline by
/// client-side binning (histogram) or treating the levels as categories
/// (funnel). Treemap / sunburst / boxWhisker have dedicated inline emitters.
///
/// This partial is on the same ChartSvgRenderer class so we have access to
/// the private helpers (HtmlEncode, colors, etc.).
/// </summary>
internal partial class ChartSvgRenderer
{
// ==================== cx → ChartInfo extraction ====================
/// <summary>
/// Extract a <see cref="ChartInfo"/> from a cx:chart element. Produces
/// the same shape the regular <c>ExtractChartInfo</c> does, so all of
/// RenderChartSvgContent's downstream emitters work without branching on
/// source format — except for the cx-specific types (treemap / sunburst /
/// boxWhisker) which dispatch to new dedicated emitters in
/// RenderChartSvgContent.
/// </summary>
public static ChartInfo ExtractCxChartInfo(CX.Chart chart, Dictionary<string, string>? themeColors = null)
{
var info = new ChartInfo();
// ---- Title ----
var chartTitle = chart.GetFirstChild<CX.ChartTitle>();
if (chartTitle != null)
{
var titleText = chartTitle.Descendants<Drawing.Text>().FirstOrDefault()?.Text;
if (!string.IsNullOrEmpty(titleText)) info.Title = titleText;
var titleRpr = chartTitle.Descendants<Drawing.RunProperties>().FirstOrDefault();
if (titleRpr?.FontSize?.HasValue == true)
info.TitleFontSize = $"{titleRpr.FontSize.Value / 100.0}pt";
// Title color: resolve srgbClr AND schemeClr/sysClr/prstClr through the theme.
var titleColor = ExtractFontColor(titleRpr, themeColors);
if (!string.IsNullOrEmpty(titleColor)) info.TitleFontColor = $"#{titleColor}";
}
// ---- Series (plot area region) ----
var plotArea = chart.GetFirstChild<CX.PlotArea>();
var plotAreaRegion = plotArea?.GetFirstChild<CX.PlotAreaRegion>();
var allSeries = plotAreaRegion?.Elements<CX.Series>().ToList() ?? new List<CX.Series>();
var chartSpace = chart.Ancestors<CX.ChartSpace>().FirstOrDefault();
var chartData = chartSpace?.GetFirstChild<CX.ChartData>();
// Determine normalized chart type from the first series' LayoutId.
// CX.SeriesLayout is a struct, not a C# enum, so we can't pattern-match
// the typed value directly — compare via InnerText.
var firstLayoutId = allSeries.FirstOrDefault()?.LayoutId?.InnerText ?? "";
info.ChartType = firstLayoutId.ToLowerInvariant() switch
{
"funnel" => "funnel",
"treemap" => "treemap",
"sunburst" => "sunburst",
"boxwhisker" => "boxwhisker",
"clusteredcolumn" => "histogram", // histogram is stored as clusteredColumn layoutId
_ => "histogram"
};
// Read each series' data from the matching cx:data block (dataId.val → data.id).
foreach (var series in allSeries)
{
var dataIdVal = series.GetFirstChild<CX.DataId>()?.Val?.Value ?? 0;
var dataBlock = chartData?.Elements<CX.Data>().FirstOrDefault(d => (d.Id?.Value ?? 0) == dataIdVal);
if (dataBlock == null) continue;
var seriesName = series.GetFirstChild<CX.Text>()
?.GetFirstChild<CX.TextData>()
?.GetFirstChild<CX.VXsdstring>()?.Text ?? "Series";
var values = dataBlock.Elements<CX.NumericDimension>()
.SelectMany(nd => nd.Descendants<CX.NumericValue>())
.Select(nv => double.TryParse(nv.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : 0.0)
.ToArray();
// Categories: strDim if present (funnel/treemap/sunburst), else values themselves (histogram)
if (info.Categories.Length == 0)
{
var catStrDim = dataBlock.Elements<CX.StringDimension>()
.FirstOrDefault(sd => sd.Type?.Value == CX.StringDimensionType.Cat);
if (catStrDim != null)
{
info.Categories = catStrDim.Descendants<CX.ChartStringValue>()
.Select(cv => cv.Text ?? "")
.ToArray();
}
}
info.Series.Add((seriesName, values));
// Series fill color — resolve srgbClr AND schemeClr/sysClr/prstClr through the
// theme map (ExtractFillColor hex-gates its result, so it is safe to interpolate).
var spPrFill = ExtractFillColor(series.GetFirstChild<CX.ShapeProperties>(), themeColors);
if (!string.IsNullOrEmpty(spPrFill))
info.Colors.Add($"#{spPrFill}");
}
// Fill in fallback colors for any series without an explicit spPr
while (info.Colors.Count < info.Series.Count)
info.Colors.Add(FallbackColors[info.Colors.Count % FallbackColors.Length]);
// ---- Histogram-specific: bin the raw values into columns ----
if (info.ChartType == "histogram" && info.Series.Count > 0)
{
var firstSeries = info.Series[0];
var binning = allSeries.FirstOrDefault()?.Descendants<CX.Binning>().FirstOrDefault();
var binCount = ReadBinCount(binning);
var binSize = ReadBinSize(binning);
var (binEdges, binCounts) = ComputeBins(firstSeries.values, binCount, binSize);
// Replace values with bin counts, and categories with bin labels
var labels = new string[binCounts.Length];
for (int i = 0; i < binCounts.Length; i++)
{
var lo = FormatNumber(binEdges[i]);
var hi = FormatNumber(binEdges[i + 1]);
labels[i] = $"[{lo},{hi}]";
}
info.Categories = labels;
info.Series[0] = (firstSeries.name, binCounts.Select(c => (double)c).ToArray());
info.GapWidth = 0; // histogram default — overridden below if cx:catScaling/@gapWidth is present
}
// ---- Axes: titles, scaling, styling ----
//
// Extracts the full per-axis vocabulary so it matches what the cx
// builder emits (ChartExBuilder.BuildCategoryAxis / BuildValueAxis):
// - axismin/axismax/majorunit → cx:valScaling @min/@max/@majorUnit
// - gapWidth → cx:catScaling @gapWidth
// - gridlineColor → cx:axis/cx:majorGridlines/cx:spPr/a:ln
// - axisline → cx:axis/cx:spPr/a:ln
// - axisfont (size+color) → cx:axis/cx:txPr/.../a:defRPr
// - axis title font/bold → cx:axis/cx:title/.../a:rPr
//
// Without these reads, any histogram that sets locked Y scale, custom
// gridline/axis-line color, custom tick-label font, or custom axis
// title bold/size renders in the HTML preview with Excel-default
// values even though the XML is correct. Excel itself renders them
// fine — this only affects officecli's in-process preview.
if (plotArea != null)
{
var axes = plotArea.Elements<CX.Axis>().ToList();
var catAxis = axes.FirstOrDefault(); // Id=0
var valAxis = axes.ElementAtOrDefault(1);
info.CatAxisTitle = ExtractAxisTitleText(catAxis);
info.ValAxisTitle = ExtractAxisTitleText(valAxis);
if (valAxis != null)
{
// Axis scaling (min/max/majorUnit) — string attributes on cx:valScaling.
var valScaling = valAxis.GetFirstChild<CX.ValueAxisScaling>();
if (valScaling != null)
{
if (double.TryParse(valScaling.Min?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var mnV))
info.AxisMin = mnV;
if (double.TryParse(valScaling.Max?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var mxV))
info.AxisMax = mxV;
if (double.TryParse(valScaling.MajorUnit?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var muV))
info.MajorUnit = muV;
}
// Axis title font size / bold
var valTitleEl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "title");
var valTitleRPr = valTitleEl?.Descendants<Drawing.RunProperties>().FirstOrDefault();
if (valTitleRPr?.FontSize?.HasValue == true)
info.ValAxisTitleFontPx = (int)(valTitleRPr.FontSize.Value / 100.0);
if (valTitleRPr?.Bold?.Value == true)
info.ValAxisTitleBold = true;
// Tick label font — cx:axis/cx:txPr/.../a:defRPr (axisfont compound knob)
var valTxPr = valAxis.Elements().FirstOrDefault(e => e.LocalName == "txPr");
var valDefRPr = valTxPr?.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
if (valDefRPr?.FontSize?.HasValue == true)
info.ValFontPx = (int)(valDefRPr.FontSize.Value / 100.0);
info.ValFontColor = ExtractFontColor(valDefRPr, themeColors);
// Major gridline color
var valGl = valAxis.Elements().FirstOrDefault(e => e.LocalName == "majorGridlines");
var valGlSpPr = valGl?.Elements().FirstOrDefault(e => e.LocalName == "spPr");
info.GridlineColor = ExtractLineColor(valGlSpPr, themeColors);
// Axis spine color
var valSpPr = valAxis.Elements().FirstOrDefault(e => e.LocalName == "spPr");
info.AxisLineColor = ExtractLineColor(valSpPr, themeColors);
}
if (catAxis != null)
{
// gapWidth — string attribute on cx:catScaling (overrides the
// histogram default of 0 set during binning above).
var catScaling = catAxis.GetFirstChild<CX.CategoryAxisScaling>();
if (catScaling?.GapWidth?.Value is string gwStr
&& int.TryParse(gwStr, out var gw))
info.GapWidth = gw;
// Axis title font size / bold
var catTitleEl = catAxis.Elements().FirstOrDefault(e => e.LocalName == "title");
var catTitleRPr = catTitleEl?.Descendants<Drawing.RunProperties>().FirstOrDefault();
if (catTitleRPr?.FontSize?.HasValue == true)
info.CatAxisTitleFontPx = (int)(catTitleRPr.FontSize.Value / 100.0);
if (catTitleRPr?.Bold?.Value == true)
info.CatAxisTitleBold = true;
// Tick label font
var catTxPr = catAxis.Elements().FirstOrDefault(e => e.LocalName == "txPr");
var catDefRPr = catTxPr?.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
if (catDefRPr?.FontSize?.HasValue == true)
info.CatFontPx = (int)(catDefRPr.FontSize.Value / 100.0);
info.CatFontColor = ExtractFontColor(catDefRPr, themeColors);
// Category-axis spine color (cataxis.line / axisline) — if
// only axisline was set, both axes received identical outlines;
// we still read cat separately so per-axis overrides work.
// valSpPr is preferred but if valAxis has none we fall back
// to catAxis for AxisLineColor.
if (info.AxisLineColor == null)
{
var catSpPr = catAxis.Elements().FirstOrDefault(e => e.LocalName == "spPr");
info.AxisLineColor = ExtractLineColor(catSpPr, themeColors);
}
}
}
// ---- Data labels (histogram) ----
//
// cx attaches dLbls to the series, not the chart type element. Read
// cx:series/cx:dataLabels/cx:visibility[@value] to decide whether
// the bar chart renderer should draw value labels above each bar.
var firstSeriesEl = allSeries.FirstOrDefault();
var dLabelsEl = firstSeriesEl?.GetFirstChild<CX.DataLabels>();
if (dLabelsEl != null)
{
var vis = dLabelsEl.GetFirstChild<CX.DataLabelVisibilities>();
if (vis?.Value?.Value == true)
{
info.ShowDataLabels = true;
info.ShowDataLabelVal = true;
}
}
// ---- Plot-area / chart-area background fills ----
// Mirrors the regular cChart path in ExtractChartInfo: read the
// spPr direct child of <cx:plotArea> and of <cx:chartSpace> and pull
// the a:solidFill/a:srgbClr value. ExtractFillColor uses LocalName
// matching so it works across c: and cx: namespaces unchanged.
//
// Downstream, PlotFillColor is painted as a <rect> inside the chart
// SVG (RenderChartSvgContent) and ChartFillColor is applied as a
// `background:` style on the chart container div (ExcelHandler
// HtmlPreview). Without these lines, cx histograms with
// `plotareafill` / `chartareafill` render on a blank white page
// even though the XML is perfectly correct — the fills only
// surface in Excel itself.
var plotSpPr = plotArea?.Elements().FirstOrDefault(e => e.LocalName == "spPr");
info.PlotFillColor = ExtractFillColor(plotSpPr, themeColors);
var chartSpPr = chartSpace?.Elements().FirstOrDefault(e => e.LocalName == "spPr");
info.ChartFillColor = ExtractFillColor(chartSpPr, themeColors);
// ---- Legend ----
// Presence-based (cx omits the element entirely to hide the legend,
// unlike c:legend which uses <c:delete val="1"/>).
var legend = chart.GetFirstChild<CX.Legend>();
info.HasLegend = legend != null;
if (legend != null)
{
// legendfont — cx:legend/cx:txPr/.../a:defRPr — compound
// "size:color:fontname" knob from the builder.
var legendTxPr = legend.Elements().FirstOrDefault(e => e.LocalName == "txPr");
var legendDefRPr = legendTxPr?.Descendants<Drawing.DefaultRunProperties>().FirstOrDefault();
if (legendDefRPr?.FontSize?.HasValue == true)
info.LegendFontSize = $"{legendDefRPr.FontSize.Value / 100.0:0.##}pt";
info.LegendFontColor = ExtractFontColor(legendDefRPr, themeColors);
}
return info;
}
private static string? ExtractAxisTitleText(CX.Axis? axis)
{
var title = axis?.GetFirstChild<CX.AxisTitle>();
if (title == null) return null;
return title.Descendants<Drawing.Text>().FirstOrDefault()?.Text;
}
// ==================== Histogram binning (client-side) ====================
// The cx binning XML uses raw OpenXmlUnknownElement children (val attribute
// workaround — see ChartExBuilder.cs notes). Read val attribute directly.
private static uint? ReadBinCount(CX.Binning? binning)
{
if (binning == null) return null;
foreach (var child in binning.ChildElements)
{
if (child.LocalName != "binCount") continue;
var val = child.GetAttributes()
.FirstOrDefault(a => a.LocalName == "val").Value;
if (uint.TryParse(val, out var n)) return n;
}
return null;
}
private static double? ReadBinSize(CX.Binning? binning)
{
if (binning == null) return null;
foreach (var child in binning.ChildElements)
{
if (child.LocalName != "binSize") continue;
var val = child.GetAttributes()
.FirstOrDefault(a => a.LocalName == "val").Value;
if (double.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out var w))
return w;
}
return null;
}
/// <summary>
/// Compute histogram bins from raw values. Matches Excel's semantics:
/// - If binCount is set, divide [min, max] into N equal-width bins.
/// - If binSize is set, width = binSize, bins anchored at min.
/// - Else auto-bin using sqrt(N) rule, clamped to [5, 20].
/// Right-closed intervals (a, b] — the default for Excel's histogram.
/// </summary>
private static (double[] edges, int[] counts) ComputeBins(double[] values, uint? binCount, double? binSize)
{
if (values.Length == 0) return (new[] { 0.0, 1.0 }, new[] { 0 });
var min = values.Min();
var max = values.Max();
if (Math.Abs(max - min) < 1e-9) { max = min + 1; }
int n;
double width;
if (binSize is double sz && sz > 0)
{
width = sz;
n = (int)Math.Max(1, Math.Ceiling((max - min) / width));
}
else
{
n = binCount is uint bc && bc > 0
? (int)bc
: (int)Math.Clamp(Math.Ceiling(Math.Sqrt(values.Length)), 5, 20);
width = (max - min) / n;
}
var edges = new double[n + 1];
for (int i = 0; i <= n; i++) edges[i] = min + width * i;
edges[n] = max; // clamp last edge to exact max to avoid FP drift
var counts = new int[n];
foreach (var v in values)
{
// Right-closed: find first bin where v <= edges[i+1]
var idx = 0;
for (int i = 0; i < n; i++)
{
if (v <= edges[i + 1]) { idx = i; break; }
idx = n - 1;
}
counts[idx]++;
}
return (edges, counts);
}
private static string FormatNumber(double v)
{
// Short display — use "G" format for compact values, no trailing zeros.
if (Math.Abs(v) >= 1000) return v.ToString("F0", CultureInfo.InvariantCulture);
if (Math.Abs(v - Math.Round(v)) < 1e-9) return v.ToString("F0", CultureInfo.InvariantCulture);
return v.ToString("0.##", CultureInfo.InvariantCulture);
}
// ==================== cx-specific SVG emitters ====================
/// <summary>
/// Render a funnel chart as centered horizontal bars. Excel funnels are
/// drawn bottom-to-top with the widest level at the top, so we reverse
/// the series order and render each level as a bar whose width is
/// proportional to its value. Simple but visually conveys the shape.
/// </summary>
public void RenderCxFunnelSvg(StringBuilder sb, ChartInfo info,
int marginLeft, int marginTop, int plotW, int plotH)
{
if (info.Series.Count == 0) return;
var values = info.Series[0].values;
var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length];
if (values.Length == 0) return;
var maxVal = values.Max();
if (maxVal <= 0) return;
var rowH = (double)plotH / values.Length;
var barH = rowH * 0.75;
// Funnel: use a single series color (or first palette entry).
// Cycling colors per level conflicts with the standard funnel look.
var color = info.Colors.FirstOrDefault() ?? DefaultColors[0];
var cx = marginLeft + plotW / 2;
for (int i = 0; i < values.Length; i++)
{
var w = (values[i] / maxVal) * plotW;
var y = marginTop + rowH * i + (rowH - barH) / 2;
var x = cx - w / 2;
sb.AppendLine($" <rect x=\"{x:F1}\" y=\"{y:F1}\" width=\"{w:F1}\" height=\"{barH:F1}\" fill=\"{color}\" rx=\"2\"/>");
// Label inside or to the right of bar
var labelX = cx;
var labelY = y + barH / 2;
var label = (cats[i] ?? "") + $" ({FormatNumber(values[i])})";
sb.AppendLine($" <text x=\"{labelX}\" y=\"{labelY:F1}\" fill=\"#fff\" font-size=\"{CatFontPx}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{HtmlEncode(label)}</text>");
}
}
/// <summary>
/// Render a treemap as a simple squarified layout. Treats all leaves as a
/// flat list (ignores hierarchy — good enough for preview). Each rectangle's
/// area is proportional to its value.
///
/// Uses Bruls/Huijbregts/van Wijk (2000) squarify with row-wise fallback:
/// pack items into strips along the shorter axis, finishing one strip
/// before starting the next.
/// </summary>
public void RenderCxTreemapSvg(StringBuilder sb, ChartInfo info,
int marginLeft, int marginTop, int plotW, int plotH)
{
if (info.Series.Count == 0) return;
var values = info.Series[0].values;
var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length];
if (values.Length == 0) return;
var total = values.Sum();
if (total <= 0) return;
// Sort descending so big rectangles go first
var order = Enumerable.Range(0, values.Length)
.Where(i => values[i] > 0)
.OrderByDescending(i => values[i]).ToArray();
// Scale values so that sum equals rectangle area — then we can talk
// directly in pixel areas for each cell.
var scale = (double)plotW * plotH / total;
var scaledVals = order.Select(i => values[i] * scale).ToArray();
// Treemap / sunburst / funnel have ONE series but N cells, so cycle
// through the palette per cell rather than painting every cell the
// same series color. Use the theme palette if available.
var palette = DefaultColors.Length > 0 ? DefaultColors : FallbackColors;
var rect = new Rect { X = marginLeft, Y = marginTop, W = plotW, H = plotH };
Squarify(scaledVals, 0, rect, (idx, r) =>
{
var origIdx = order[idx];
var color = palette[origIdx % palette.Length];
sb.AppendLine($" <rect x=\"{r.X:F1}\" y=\"{r.Y:F1}\" width=\"{r.W:F1}\" height=\"{r.H:F1}\" fill=\"{color}\" stroke=\"#fff\" stroke-width=\"1.5\"/>");
if (r.W > 40 && r.H > 18)
{
var label = cats[origIdx] ?? "";
sb.AppendLine($" <text x=\"{r.X + r.W / 2:F1}\" y=\"{r.Y + r.H / 2:F1}\" fill=\"#fff\" font-size=\"{CatFontPx}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{HtmlEncode(label)}</text>");
}
});
}
private struct Rect { public double X, Y, W, H; }
/// <summary>
/// Classic squarify algorithm (Bruls et al. 2000), simplified: greedily
/// group items into strips along the shorter side of the remaining rect,
/// committing the strip when adding one more item would worsen the worst
/// aspect ratio of the current group. Each committed strip consumes the
/// full shorter side; remaining items fill the leftover rectangle.
/// </summary>
private static void Squarify(double[] areas, int start, Rect rect, Action<int, Rect> emit)
{
if (start >= areas.Length || rect.W <= 0.5 || rect.H <= 0.5) return;
// Convention: the "strip" is placed along the SHORT side. If the
// rectangle is WIDE (W > H), the strip is a vertical column at the
// left edge (full H tall, stripW wide). If the rectangle is TALL
// (H > W), the strip is a horizontal row at the top edge (full W
// wide, stripH tall). Items stack ALONG the short side (vertically
// in a wide rect, horizontally in a tall rect).
var shortSide = Math.Min(rect.W, rect.H);
// Greedily extend the current row as long as aspect ratio improves
// (or stays equal). Stop and commit when the next item would make
// the worst aspect ratio worse.
int end = start + 1;
double bestWorst = RowWorstRatio(areas, start, end, shortSide);
while (end < areas.Length)
{
var tryEnd = end + 1;
var tryWorst = RowWorstRatio(areas, start, tryEnd, shortSide);
if (tryWorst <= bestWorst)
{
end = tryEnd;
bestWorst = tryWorst;
}
else break;
}
// Emit the committed row
var stripAdvance = LayoutRow(areas, start, end, rect, emit);
// Recurse on the leftover rectangle (the part outside the strip).
Rect remaining = rect.W >= rect.H
// Wide rect → vertical strip at left → recurse on right slab
? new Rect { X = rect.X + stripAdvance, Y = rect.Y, W = rect.W - stripAdvance, H = rect.H }
// Tall rect → horizontal strip at top → recurse on bottom slab
: new Rect { X = rect.X, Y = rect.Y + stripAdvance, W = rect.W, H = rect.H - stripAdvance };
Squarify(areas, end, remaining, emit);
}
/// <summary>
/// Worst aspect ratio for the proposed row (items [start, end)) packed
/// along a strip of length <paramref name="shortSide"/>. Each item then
/// has one dimension = stripThickness = rowSum/shortSide and the other
/// = a_i / stripThickness. Per Bruls et al.:
/// worst = max(max_i(w² · a_max / s²), max_i(s² / (w² · a_min)))
/// </summary>
private static double RowWorstRatio(double[] areas, int start, int end, double shortSide)
{
if (end <= start) return double.MaxValue;
double s = 0;
double maxArea = 0, minArea = double.MaxValue;
for (int i = start; i < end; i++)
{
s += areas[i];
if (areas[i] > maxArea) maxArea = areas[i];
if (areas[i] < minArea) minArea = areas[i];
}
if (s <= 0 || shortSide <= 0) return double.MaxValue;
var sqSide = shortSide * shortSide;
var a = (sqSide * maxArea) / (s * s);
var b = (s * s) / (sqSide * Math.Max(minArea, 1e-9));
return Math.Max(a, b);
}
/// <summary>
/// Lay out a committed row inside <paramref name="rect"/> and call
/// <paramref name="emit"/> for each item. Returns how far the strip
/// advanced along the LONG side of the rectangle — the caller uses
/// this to compute the leftover rectangle.
/// </summary>
private static double LayoutRow(double[] areas, int start, int end, Rect rect, Action<int, Rect> emit)
{
double rowSum = 0;
for (int i = start; i < end; i++) rowSum += areas[i];
if (rowSum <= 0) return 0;
var wideRect = rect.W >= rect.H;
var shortSide = Math.Min(rect.W, rect.H);
var stripThickness = rowSum / shortSide; // strip depth along long side
// Items inside the strip have one fixed side = stripThickness and
// the other side = a_i / stripThickness. They stack along the short
// side of the original rect.
var cursor = 0.0;
for (int i = start; i < end; i++)
{
var itemLenAlongShort = areas[i] / stripThickness;
Rect r;
if (wideRect)
{
// Strip is a vertical column at rect.X, full height stacked.
r = new Rect
{
X = rect.X,
Y = rect.Y + cursor,
W = stripThickness,
H = itemLenAlongShort,
};
}
else
{
// Strip is a horizontal row at rect.Y, full width packed.
r = new Rect
{
X = rect.X + cursor,
Y = rect.Y,
W = itemLenAlongShort,
H = stripThickness,
};
}
emit(i, r);
cursor += itemLenAlongShort;
}
return stripThickness;
}
/// <summary>
/// Render a sunburst as concentric arcs. Without full hierarchy info we
/// just draw a single ring with one slice per value (like a pie chart
/// with a large hole). Good enough for previews.
/// </summary>
public void RenderCxSunburstSvg(StringBuilder sb, ChartInfo info,
int marginLeft, int marginTop, int plotW, int plotH)
{
if (info.Series.Count == 0) return;
var values = info.Series[0].values;
var cats = info.Categories.Length == values.Length ? info.Categories : new string[values.Length];
var total = values.Sum();
if (total <= 0) return;
var cx = marginLeft + plotW / 2.0;
var cy = marginTop + plotH / 2.0;
var rOuter = Math.Min(plotW, plotH) / 2.0 - 10;
var rInner = rOuter * 0.35;
var palette = DefaultColors.Length > 0 ? DefaultColors : FallbackColors;
var startAngle = -Math.PI / 2; // start at 12 o'clock
for (int i = 0; i < values.Length; i++)
{
var sweep = (values[i] / total) * 2 * Math.PI;
if (sweep <= 0) continue;
var endAngle = startAngle + sweep;
var largeArc = sweep > Math.PI ? 1 : 0;
var x1 = cx + rOuter * Math.Cos(startAngle);
var y1 = cy + rOuter * Math.Sin(startAngle);
var x2 = cx + rOuter * Math.Cos(endAngle);
var y2 = cy + rOuter * Math.Sin(endAngle);
var ix1 = cx + rInner * Math.Cos(endAngle);
var iy1 = cy + rInner * Math.Sin(endAngle);
var ix2 = cx + rInner * Math.Cos(startAngle);
var iy2 = cy + rInner * Math.Sin(startAngle);
var d = $"M {x1:F1},{y1:F1} A {rOuter:F1},{rOuter:F1} 0 {largeArc} 1 {x2:F1},{y2:F1} "
+ $"L {ix1:F1},{iy1:F1} A {rInner:F1},{rInner:F1} 0 {largeArc} 0 {ix2:F1},{iy2:F1} Z";
var color = palette[i % palette.Length];
sb.AppendLine($" <path d=\"{d}\" fill=\"{color}\" stroke=\"#fff\" stroke-width=\"1\"/>");
// Label in the middle of the arc
var midAngle = startAngle + sweep / 2;
var labelR = (rOuter + rInner) / 2;
var lx = cx + labelR * Math.Cos(midAngle);
var ly = cy + labelR * Math.Sin(midAngle);
var label = cats[i] ?? "";
if (sweep > 0.25 && !string.IsNullOrEmpty(label))
sb.AppendLine($" <text x=\"{lx:F1}\" y=\"{ly:F1}\" fill=\"#fff\" font-size=\"{CatFontPx}\" text-anchor=\"middle\" dominant-baseline=\"middle\">{HtmlEncode(label)}</text>");
startAngle = endAngle;
}
}
/// <summary>
/// Render a box-whisker chart. For each series: box (Q1Q3), median line,
/// whiskers extending to the last non-outlier value within 1.5×IQR of the
/// fence, outlier data points drawn as open circles, and a mean marker (×).
/// </summary>
public void RenderCxBoxWhiskerSvg(StringBuilder sb, ChartInfo info,
int marginLeft, int marginTop, int plotW, int plotH)
{
if (info.Series.Count == 0) return;
// Compute stats per series
var stats = info.Series.Select(s => ComputeBoxStats(s.values)).ToList();
if (stats.All(s => s == null)) return;
// Global scale includes outliers
var globalMin = stats.Where(s => s != null).Min(s => s!.Value.allMin);
var globalMax = stats.Where(s => s != null).Max(s => s!.Value.allMax);
if (Math.Abs(globalMax - globalMin) < 1e-9) globalMax = globalMin + 1;
// Add 5% padding so top/bottom outliers aren't clipped at the edge
var pad = (globalMax - globalMin) * 0.05;
globalMin -= pad;
globalMax += pad;
var bw = (double)plotW / info.Series.Count;
var boxW = bw * 0.5;
double yCoord(double v) => marginTop + plotH - ((v - globalMin) / (globalMax - globalMin)) * plotH;
// Y axis: a few tick labels for context
for (int t = 0; t <= 4; t++)
{
var v = globalMin + pad + (globalMax - globalMin - 2 * pad) * t / 4;
var y = yCoord(v);
sb.AppendLine($" <line x1=\"{marginLeft}\" y1=\"{y:F1}\" x2=\"{marginLeft + plotW}\" y2=\"{y:F1}\" stroke=\"{GridColor}\" stroke-dasharray=\"2,2\"/>");
sb.AppendLine($" <text x=\"{marginLeft - 3}\" y=\"{y:F1}\" fill=\"{AxisColor}\" font-size=\"{ValFontPx}\" text-anchor=\"end\" dominant-baseline=\"middle\">{FormatNumber(v)}</text>");
}
for (int si = 0; si < info.Series.Count; si++)
{
if (stats[si] is not { } s) continue;
var color = info.Colors[si % info.Colors.Count];
var cxCenter = marginLeft + bw * (si + 0.5);
var boxX = cxCenter - boxW / 2;
var yWLow = yCoord(s.whiskerLow);
var yWHigh = yCoord(s.whiskerHigh);
var yQ1 = yCoord(s.q1);
var yQ3 = yCoord(s.q3);
var yMed = yCoord(s.median);
var yMean = yCoord(s.mean);
// Whisker vertical line: Q1→whiskerLow and Q3→whiskerHigh
sb.AppendLine($" <line x1=\"{cxCenter:F1}\" y1=\"{yWLow:F1}\" x2=\"{cxCenter:F1}\" y2=\"{yQ1:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
sb.AppendLine($" <line x1=\"{cxCenter:F1}\" y1=\"{yQ3:F1}\" x2=\"{cxCenter:F1}\" y2=\"{yWHigh:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
// Whisker caps (horizontal ticks at fence endpoints)
var capHalf = boxW * 0.3;
sb.AppendLine($" <line x1=\"{cxCenter - capHalf:F1}\" y1=\"{yWLow:F1}\" x2=\"{cxCenter + capHalf:F1}\" y2=\"{yWLow:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
sb.AppendLine($" <line x1=\"{cxCenter - capHalf:F1}\" y1=\"{yWHigh:F1}\" x2=\"{cxCenter + capHalf:F1}\" y2=\"{yWHigh:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
// Box Q1..Q3
sb.AppendLine($" <rect x=\"{boxX:F1}\" y=\"{yWHigh:F1}\" width=\"{boxW:F1}\" height=\"{yWLow - yWHigh:F1}\" fill=\"{color}\" fill-opacity=\"0.25\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
// Median line
sb.AppendLine($" <line x1=\"{boxX:F1}\" y1=\"{yMed:F1}\" x2=\"{boxX + boxW:F1}\" y2=\"{yMed:F1}\" stroke=\"{color}\" stroke-width=\"2.5\"/>");
// Mean marker: × symbol
var mx = 4.0;
sb.AppendLine($" <line x1=\"{cxCenter - mx:F1}\" y1=\"{yMean - mx:F1}\" x2=\"{cxCenter + mx:F1}\" y2=\"{yMean + mx:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
sb.AppendLine($" <line x1=\"{cxCenter + mx:F1}\" y1=\"{yMean - mx:F1}\" x2=\"{cxCenter - mx:F1}\" y2=\"{yMean + mx:F1}\" stroke=\"{color}\" stroke-width=\"1.5\"/>");
// Outlier circles
const double r = 3.5;
foreach (var ov in s.outliers)
{
var yo = yCoord(ov);
sb.AppendLine($" <circle cx=\"{cxCenter:F1}\" cy=\"{yo:F1}\" r=\"{r}\" fill=\"none\" stroke=\"{color}\" stroke-width=\"1.2\"/>");
}
// Series label
sb.AppendLine($" <text x=\"{cxCenter:F1}\" y=\"{marginTop + plotH + 14}\" fill=\"{AxisColor}\" font-size=\"{CatFontPx}\" text-anchor=\"middle\">{HtmlEncode(info.Series[si].name)}</text>");
}
}
private record struct BoxStats(
double whiskerLow, double q1, double median, double q3, double whiskerHigh,
double mean, double allMin, double allMax, double[] outliers);
private static BoxStats? ComputeBoxStats(double[] values)
{
if (values.Length == 0) return null;
var sorted = values.OrderBy(v => v).ToArray();
double Percentile(double p)
{
if (sorted.Length == 1) return sorted[0];
var idx = p * (sorted.Length - 1);
var lo = (int)Math.Floor(idx);
var hi = (int)Math.Ceiling(idx);
var frac = idx - lo;
return sorted[lo] * (1 - frac) + sorted[hi] * frac;
}
var q1 = Percentile(0.25);
var q3 = Percentile(0.75);
var iqr = q3 - q1;
var fenceLow = q1 - 1.5 * iqr;
var fenceHigh = q3 + 1.5 * iqr;
// Whiskers extend to the last data point within the fence
var whiskerLow = sorted.Where(v => v >= fenceLow).DefaultIfEmpty(q1).Min();
var whiskerHigh = sorted.Where(v => v <= fenceHigh).DefaultIfEmpty(q3).Max();
var outliers = sorted.Where(v => v < fenceLow || v > fenceHigh).ToArray();
var mean = sorted.Average();
return new BoxStats(
whiskerLow, q1, Percentile(0.5), q3, whiskerHigh,
mean, sorted[0], sorted[^1], outliers);
}
/// <summary>
/// Dispatcher entry for cx chart types that aren't reducible to the
/// regular bar/column pipeline. Histogram → RenderBarChartSvg (handled
/// by the main dispatcher after ExtractCxChartInfo pre-bins the data).
/// </summary>
public bool TryRenderCxSpecificType(StringBuilder sb, ChartInfo info,
int marginLeft, int marginTop, int plotW, int plotH)
{
switch (info.ChartType)
{
case "funnel":
RenderCxFunnelSvg(sb, info, marginLeft, marginTop, plotW, plotH);
return true;
case "treemap":
RenderCxTreemapSvg(sb, info, marginLeft, marginTop, plotW, plotH);
return true;
case "sunburst":
RenderCxSunburstSvg(sb, info, marginLeft, marginTop, plotW, plotH);
return true;
case "boxwhisker":
RenderCxBoxWhiskerSvg(sb, info, marginLeft, marginTop, plotW, plotH);
return true;
}
return false;
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Exception that carries structured error info for AI-friendly JSON output.
/// </summary>
public class CliException : Exception
{
/// <summary>Suggested correction (e.g. correct property name).</summary>
public string? Suggestion { get; init; }
/// <summary>Help command the caller can run for more info.</summary>
public string? Help { get; init; }
/// <summary>Machine-readable error code (e.g. "not_found", "invalid_value", "unsupported_property").</summary>
public string? Code { get; init; }
/// <summary>Available valid values when the error is about an invalid choice.</summary>
public string[]? ValidValues { get; init; }
public CliException(string message) : base(message) { }
public CliException(string message, Exception innerException) : base(message, innerException) { }
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Simple file logger. Enabled via: officecli config log true
/// Logs to ~/.officecli/officecli.log (max 1 MB, auto-trimmed)
/// </summary>
internal static class CliLogger
{
private static readonly string LogPath = Path.Combine(UpdateChecker.ConfigDir, "officecli.log");
private const long MaxLogSize = 1024 * 1024; // 1 MB
internal static bool Enabled
{
get
{
try { return UpdateChecker.LoadConfig().Log; }
catch { return false; }
}
}
internal static void LogCommand(string[] args)
{
if (!Enabled || args.Length == 0) return;
// Skip internal commands
if (args[0].StartsWith("__") && args[0].EndsWith("__")) return;
Write($"> officecli {string.Join(" ", args)}");
}
internal static void Clear()
{
try { File.Delete(LogPath); }
catch { }
}
internal static void LogOutput(string output)
{
if (!Enabled || string.IsNullOrEmpty(output)) return;
Write(output);
}
internal static void LogError(string error)
{
if (!Enabled || string.IsNullOrEmpty(error)) return;
Write($"[ERROR] {error}");
}
private static void Write(string message)
{
try
{
Directory.CreateDirectory(UpdateChecker.ConfigDir);
var escaped = message.ReplaceLineEndings("\\n");
TrimIfNeeded();
File.AppendAllText(LogPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {escaped}\n");
}
catch
{
// Logging should never break the CLI
}
}
private static void TrimIfNeeded()
{
var info = new FileInfo(LogPath);
if (!info.Exists || info.Length <= MaxLogSize) return;
// Keep the last half of the file
var text = File.ReadAllText(LogPath);
var half = text.Length / 2;
var start = text.IndexOf('\n', half);
if (start < 0 || start >= text.Length - 1) return;
File.WriteAllText(LogPath, text[(start + 1)..]);
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Shared RGB↔HSL color space conversion and OOXML color transform helpers.
/// Extracted from PowerPointHandler.HtmlPreview.Css and WordHandler.HtmlPreview.Css
/// to eliminate duplication.
/// </summary>
internal static class ColorMath
{
/// <summary>Convert RGB (0-255) to HSL (h: 0-1, s: 0-1, l: 0-1).</summary>
public static void RgbToHsl(int r, int g, int b, out double h, out double s, out double l)
{
var rf = r / 255.0;
var gf = g / 255.0;
var bf = b / 255.0;
var max = Math.Max(rf, Math.Max(gf, bf));
var min = Math.Min(rf, Math.Min(gf, bf));
var delta = max - min;
l = (max + min) / 2.0;
if (delta < 1e-10)
{
h = 0;
s = 0;
return;
}
s = l < 0.5 ? delta / (max + min) : delta / (2.0 - max - min);
if (Math.Abs(max - rf) < 1e-10)
h = ((gf - bf) / delta + (gf < bf ? 6 : 0)) / 6.0;
else if (Math.Abs(max - gf) < 1e-10)
h = ((bf - rf) / delta + 2) / 6.0;
else
h = ((rf - gf) / delta + 4) / 6.0;
}
/// <summary>Convert HSL (h: 0-1, s: 0-1, l: 0-1) to RGB (0-255).</summary>
public static void HslToRgb(double h, double s, double l, out int r, out int g, out int b)
{
if (s < 1e-10)
{
r = g = b = (int)Math.Round(l * 255);
return;
}
var q = l < 0.5 ? l * (1 + s) : l + s - l * s;
var p = 2 * l - q;
r = (int)Math.Round(HueToRgb(p, q, h + 1.0 / 3) * 255);
g = (int)Math.Round(HueToRgb(p, q, h) * 255);
b = (int)Math.Round(HueToRgb(p, q, h - 1.0 / 3) * 255);
}
/// <summary>Helper for HSL→RGB conversion.</summary>
internal static double HueToRgb(double p, double q, double t)
{
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1.0 / 6) return p + (q - p) * 6 * t;
if (t < 1.0 / 2) return q;
if (t < 2.0 / 3) return p + (q - p) * (2.0 / 3 - t) * 6;
return p;
}
/// <summary>
/// Parse the first 6 hex digits of an RGB string into (R, G, B) byte components (0255).
/// Caller must ensure the input has at least 6 hex digits and no leading '#'.
/// Any trailing data (e.g. an 8-digit AARRGGBB alpha tail) is ignored.
/// </summary>
public static (int R, int G, int B) HexToRgb(string hex) =>
(Convert.ToInt32(hex[..2], 16), Convert.ToInt32(hex[2..4], 16), Convert.ToInt32(hex[4..6], 16));
/// <summary>
/// Apply OOXML lumMod/lumOff color transform in HSL space.
/// lumMod and lumOff are in 0100000 units (percentage × 1000).
/// Formula: newL = clamp(L × lumMod/100000 + lumOff/100000, 0, 1)
/// </summary>
public static string ApplyLumModOff(string hex, int lumMod, int lumOff)
{
var (r, g, b) = ColorMath.HexToRgb(hex);
RgbToHsl(r, g, b, out var h, out var s, out var l);
l = Math.Clamp(l * (lumMod / 100000.0) + (lumOff / 100000.0), 0, 1);
HslToRgb(h, s, l, out r, out g, out b);
r = Math.Clamp(r, 0, 255);
g = Math.Clamp(g, 0, 255);
b = Math.Clamp(b, 0, 255);
return $"#{r:X2}{g:X2}{b:X2}";
}
/// <summary>
/// Apply OOXML DrawingML color transforms: tint, shade, lumMod, lumOff, satMod, alpha.
/// All values in 0100000 units (percentage × 1000). Pass null to skip a transform.
/// Input hex is 6-char without '#' prefix. Output includes '#' prefix (or rgba() if alpha &lt; 100000).
/// </summary>
public static string ApplyTransforms(string hex, int? tint = null, int? shade = null,
int? lumMod = null, int? lumOff = null, int? alpha = null, int? satMod = null)
{
var (r, g, b) = ColorMath.HexToRgb(hex);
// OOXML spec: tint blends toward white, shade blends toward black
if (tint.HasValue)
{
var t = tint.Value / 100000.0;
r = (int)(r + (255 - r) * (1 - t));
g = (int)(g + (255 - g) * (1 - t));
b = (int)(b + (255 - b) * (1 - t));
}
if (shade.HasValue)
{
var s = shade.Value / 100000.0;
r = (int)(r * s);
g = (int)(g * s);
b = (int)(b * s);
}
// OOXML spec: lumMod/lumOff and satMod operate in HSL space.
// Fold them into a single HSL round-trip so a color with both gets
// S and L modulation applied together (no double-convert).
if (lumMod.HasValue || lumOff.HasValue || satMod.HasValue)
{
var mod = (lumMod ?? 100000) / 100000.0;
var off = (lumOff ?? 0) / 100000.0;
RgbToHsl(r, g, b, out var h, out var s, out var l);
if (satMod.HasValue)
s = Math.Clamp(s * (satMod.Value / 100000.0), 0, 1);
l = Math.Clamp(l * mod + off, 0, 1);
HslToRgb(h, s, l, out r, out g, out b);
}
r = Math.Clamp(r, 0, 255);
g = Math.Clamp(g, 0, 255);
b = Math.Clamp(b, 0, 255);
if (alpha.HasValue && alpha.Value < 100000)
return $"rgba({r},{g},{b},{alpha.Value / 100000.0:0.##})";
return $"#{r:X2}{g:X2}{b:X2}";
}
}
+388
View File
@@ -0,0 +1,388 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Buffers.Binary;
using System.Text;
namespace OfficeCli.Core;
/// <summary>
/// Self-contained reader/writer for the Microsoft Compound File Binary
/// (CFB / OLE structured storage, the <c>D0 CF 11 E0</c> container) format,
/// scoped to exactly what OLE embedding needs: a single named stream inside
/// the root storage.
///
/// This replaces the former third-party OpenMcdf dependency so the OLE
/// wrap/unwrap path is fully owned in-tree. The implementation follows
/// [MS-CFB]:
///
/// <list type="bullet">
/// <item>Writer emits a V3 container (512-byte sectors). Streams smaller
/// than the 4096-byte mini-stream cutoff are stored in the mini stream
/// via the mini FAT; larger streams go in regular FAT sectors. Both
/// paths are implemented because embedded payloads vary in size and a
/// spec-compliant reader (real Office, the former OpenMcdf) decides which
/// region to read from purely off the recorded stream size.</item>
/// <item>Reader parses V3 <i>and</i> V4 containers (sector size taken from
/// the header sector shift) defensively, since the bytes it unwraps may
/// originate from any tool. Every offset is bounds-checked and chain
/// traversal is iteration-capped; malformed input yields <c>null</c>
/// rather than throwing, so callers fall back to the raw bytes.</item>
/// </list>
/// </summary>
internal static class CompoundFile
{
private const uint FREESECT = 0xFFFFFFFF;
private const uint ENDOFCHAIN = 0xFFFFFFFE;
private const uint FATSECT = 0xFFFFFFFD;
private const uint NOSTREAM = 0xFFFFFFFF;
private const int MiniSectorSize = 64;
private const int MiniStreamCutoff = 4096;
private const int DirEntrySize = 128;
private const int HeaderDifatCount = 109; // FAT sector slots stored in the header
private static readonly byte[] Magic =
{ 0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1 };
// ==================== Writer ====================
/// <summary>
/// Build a minimal V3 CFB byte array whose root storage contains a single
/// stream named <paramref name="streamName"/> holding <paramref name="data"/>.
/// </summary>
public static byte[] WriteSingleStream(string streamName, byte[] data)
{
if (streamName == null) throw new ArgumentNullException(nameof(streamName));
data ??= Array.Empty<byte>();
const int sectorSize = 512;
var sectors = new List<byte[]>(); // regular sector payloads, id == index
var fat = new List<uint>(); // FAT entry per sector (parallel to sectors)
// Append a fresh chain of regular sectors holding blob; return start id.
int WriteChain(byte[] blob)
{
int n = Math.Max(1, (blob.Length + sectorSize - 1) / sectorSize);
int start = sectors.Count;
for (int i = 0; i < n; i++)
{
var s = new byte[sectorSize];
int off = i * sectorSize;
int len = Math.Min(sectorSize, blob.Length - off);
if (len > 0) Array.Copy(blob, off, s, 0, len);
sectors.Add(s);
fat.Add(0);
}
for (int i = 0; i < n; i++)
fat[start + i] = i == n - 1 ? ENDOFCHAIN : (uint)(start + i + 1);
return start;
}
uint rootStart = ENDOFCHAIN;
long rootSize = 0;
uint streamStart;
uint firstMiniFat = ENDOFCHAIN;
uint numMiniFat = 0;
if (data.Length == 0)
{
streamStart = ENDOFCHAIN;
}
else if (data.Length < MiniStreamCutoff)
{
// Small stream → mini stream + mini FAT.
int numMini = (data.Length + MiniSectorSize - 1) / MiniSectorSize;
int miniBytes = numMini * MiniSectorSize;
var miniStream = new byte[miniBytes];
Array.Copy(data, miniStream, data.Length);
rootStart = (uint)WriteChain(miniStream);
rootSize = miniBytes;
int miniFatSectors = (numMini + 127) / 128;
var miniFatBlob = new byte[miniFatSectors * sectorSize];
// Default every slot to FREESECT, then lay down the 0→1→…→END chain.
for (int i = 0; i < miniFatBlob.Length; i += 4)
BinaryPrimitives.WriteUInt32LittleEndian(miniFatBlob.AsSpan(i), FREESECT);
for (int i = 0; i < numMini; i++)
BinaryPrimitives.WriteUInt32LittleEndian(
miniFatBlob.AsSpan(i * 4),
i == numMini - 1 ? ENDOFCHAIN : (uint)(i + 1));
firstMiniFat = (uint)WriteChain(miniFatBlob);
numMiniFat = (uint)miniFatSectors;
streamStart = 0; // first mini-sector index
}
else
{
// Large stream → regular FAT sectors.
streamStart = (uint)WriteChain(data);
}
// Directory: Root Entry + the stream entry, padded to a 512-byte sector.
var dir = new byte[4 * DirEntrySize];
WriteDirEntry(dir, 0 * DirEntrySize, "Root Entry", objType: 5,
left: NOSTREAM, right: NOSTREAM, child: 1, start: rootStart, size: rootSize);
WriteDirEntry(dir, 1 * DirEntrySize, streamName, objType: 2,
left: NOSTREAM, right: NOSTREAM, child: NOSTREAM,
start: streamStart, size: data.Length);
// Entries 2 and 3 stay zeroed (objType 0 = unallocated).
int dirStart = WriteChain(dir);
// Allocate FAT sectors last: they must also be represented in the FAT.
int dataSectors = sectors.Count;
int numFat = Math.Max(1, (dataSectors + 126) / 127); // ceil(dataSectors/127)
// Adding numFat sectors may push us over another FAT-sector boundary.
while (dataSectors + numFat > numFat * 128) numFat++;
if (numFat > HeaderDifatCount)
throw new NotSupportedException(
"OLE payload too large to wrap (would need DIFAT sectors).");
var fatSectorIds = new int[numFat];
for (int i = 0; i < numFat; i++)
{
fatSectorIds[i] = sectors.Count;
sectors.Add(new byte[sectorSize]);
fat.Add(FATSECT);
}
// Serialize the FAT array across the FAT sectors (tail = FREESECT).
var fatBytes = new byte[numFat * sectorSize];
for (int i = 0; i < fatBytes.Length; i += 4)
BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(i), FREESECT);
for (int s = 0; s < fat.Count; s++)
BinaryPrimitives.WriteUInt32LittleEndian(fatBytes.AsSpan(s * 4), fat[s]);
for (int i = 0; i < numFat; i++)
Array.Copy(fatBytes, i * sectorSize, sectors[fatSectorIds[i]], 0, sectorSize);
// Header (512 bytes) + every sector in id order.
var header = new byte[sectorSize];
Array.Copy(Magic, header, Magic.Length);
// CLSID (8..23) stays zero.
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(24), 0x003E); // minor version
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(26), 0x0003); // major version (V3)
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(28), 0xFFFE); // byte order
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(30), 0x0009); // sector shift → 512
BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(32), 0x0006); // mini sector shift → 64
// reserved (34..39) zero, numDirSectors (40) = 0 for V3.
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(44), (uint)numFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(48), (uint)dirStart);
// transaction signature (52) zero.
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(56), MiniStreamCutoff);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(60), firstMiniFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(64), numMiniFat);
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(68), ENDOFCHAIN); // first DIFAT sector
BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(72), 0); // num DIFAT sectors
// DIFAT array at 76: FAT sector ids, rest FREESECT.
for (int i = 0; i < HeaderDifatCount; i++)
BinaryPrimitives.WriteUInt32LittleEndian(
header.AsSpan(76 + i * 4),
i < numFat ? (uint)fatSectorIds[i] : FREESECT);
var output = new byte[sectorSize + sectors.Count * sectorSize];
Array.Copy(header, 0, output, 0, sectorSize);
for (int i = 0; i < sectors.Count; i++)
Array.Copy(sectors[i], 0, output, sectorSize + i * sectorSize, sectorSize);
return output;
}
private static void WriteDirEntry(byte[] buf, int offset, string name,
byte objType, uint left, uint right, uint child, uint start, long size)
{
var nameBytes = Encoding.Unicode.GetBytes(name);
int copyLen = Math.Min(nameBytes.Length, 62); // 31 UTF-16 chars + null = 64 bytes
Array.Copy(nameBytes, 0, buf, offset, copyLen);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(offset + 64), (ushort)(copyLen + 2));
buf[offset + 66] = objType;
buf[offset + 67] = 1; // colorFlag: black
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 68), left);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 72), right);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 76), child);
// CLSID (80..95), stateBits (96..99), timestamps (100..115) stay zero.
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(offset + 116), start);
BinaryPrimitives.WriteUInt64LittleEndian(buf.AsSpan(offset + 120), (ulong)size);
}
// ==================== Reader ====================
/// <summary>
/// Extract the stream named <paramref name="streamName"/> from a CFB byte
/// array. Returns the stream bytes, or <c>null</c> if the input is not a
/// valid CFB, the stream is absent, or any structural inconsistency is hit.
/// </summary>
public static byte[]? ReadStream(byte[] cfb, string streamName)
{
try
{
if (cfb == null || cfb.Length < 512) return null;
for (int i = 0; i < Magic.Length; i++)
if (cfb[i] != Magic[i]) return null;
int sectorShift = BinaryPrimitives.ReadUInt16LittleEndian(cfb.AsSpan(30));
if (sectorShift < 7 || sectorShift > 20) return null;
int sectorSize = 1 << sectorShift;
if (sectorSize < 128) return null;
uint numFat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(44));
uint firstDir = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(48));
uint miniCutoff = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(56));
uint firstMiniFat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(60));
uint firstDifat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(68));
uint numDifat = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(72));
int totalSectors = (cfb.Length - sectorSize) / sectorSize;
if (totalSectors <= 0) return null;
int SectorOffset(uint id) => sectorSize + (int)id * sectorSize;
bool ValidSector(uint id) => id < (uint)totalSectors;
// Gather FAT sector ids: header DIFAT (109) then any DIFAT sectors.
var fatSectorIds = new List<uint>();
for (int i = 0; i < HeaderDifatCount && fatSectorIds.Count < numFat; i++)
{
uint id = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(76 + i * 4));
if (id == FREESECT) break;
fatSectorIds.Add(id);
}
uint difatCur = firstDifat;
int difatGuard = 0;
int perDifat = sectorSize / 4 - 1;
while (difatCur != ENDOFCHAIN && difatCur != FREESECT &&
fatSectorIds.Count < numFat && difatGuard++ <= totalSectors + 1)
{
if (!ValidSector(difatCur)) return null;
int baseOff = SectorOffset(difatCur);
for (int i = 0; i < perDifat && fatSectorIds.Count < numFat; i++)
{
uint id = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
if (id == FREESECT) continue;
fatSectorIds.Add(id);
}
difatCur = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + perDifat * 4));
}
if (numDifat > 0) { /* followed above; count is advisory */ }
// Build the full FAT array.
int fatEntries = fatSectorIds.Count * (sectorSize / 4);
var fat = new uint[fatEntries];
int w = 0;
foreach (uint fid in fatSectorIds)
{
if (!ValidSector(fid)) return null;
int baseOff = SectorOffset(fid);
for (int i = 0; i < sectorSize / 4; i++)
fat[w++] = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
}
// Follow a FAT chain; cap iterations to guard against cycles.
List<uint>? Chain(uint start)
{
var ids = new List<uint>();
uint cur = start;
int guard = 0;
while (cur != ENDOFCHAIN && cur != FREESECT)
{
if (cur >= (uint)fat.Length || !ValidSector(cur)) return null;
if (guard++ > totalSectors + 1) return null;
ids.Add(cur);
cur = fat[cur];
}
return ids;
}
byte[]? ReadRegular(uint start, long size)
{
var ids = Chain(start);
if (ids == null) return null;
var buf = new byte[ids.Count * sectorSize];
for (int i = 0; i < ids.Count; i++)
Array.Copy(cfb, SectorOffset(ids[i]), buf, i * sectorSize, sectorSize);
if (size < 0 || size > buf.Length) return buf;
var outBuf = new byte[size];
Array.Copy(buf, outBuf, (int)size);
return outBuf;
}
// Parse the directory and locate Root Entry + the target stream.
var dirChain = Chain(firstDir);
if (dirChain == null) return null;
var target = Encoding.Unicode.GetBytes(streamName);
uint rootStart = ENDOFCHAIN;
long rootSize = 0;
bool foundStream = false;
uint streamStart = ENDOFCHAIN;
long streamSize = 0;
foreach (uint dsec in dirChain)
{
int baseOff = SectorOffset(dsec);
for (int e = 0; e + DirEntrySize <= sectorSize; e += DirEntrySize)
{
int off = baseOff + e;
byte objType = cfb[off + 66];
if (objType == 5) // root
{
rootStart = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(off + 116));
rootSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(cfb.AsSpan(off + 120));
}
else if (objType == 2 && !foundStream) // stream
{
int nameLen = BinaryPrimitives.ReadUInt16LittleEndian(cfb.AsSpan(off + 64));
nameLen = Math.Clamp(nameLen, 0, 64);
int cmpLen = nameLen >= 2 ? nameLen - 2 : 0; // drop null terminator
if (cmpLen == target.Length && cfb.AsSpan(off, cmpLen).SequenceEqual(target))
{
streamStart = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(off + 116));
streamSize = (long)BinaryPrimitives.ReadUInt64LittleEndian(cfb.AsSpan(off + 120));
foundStream = true;
}
}
}
}
if (!foundStream) return null;
if (streamSize == 0) return Array.Empty<byte>();
// Large stream: read straight from the regular FAT.
if (miniCutoff == 0 || streamSize >= miniCutoff)
return ReadRegular(streamStart, streamSize);
// Small stream: read from the mini stream via the mini FAT.
byte[]? miniStream = ReadRegular(rootStart, rootSize);
if (miniStream == null) return null;
var miniFatChain = Chain(firstMiniFat);
if (miniFatChain == null) return null;
int miniFatEntries = miniFatChain.Count * (sectorSize / 4);
var miniFat = new uint[miniFatEntries];
int mw = 0;
foreach (uint mfid in miniFatChain)
{
int baseOff = SectorOffset(mfid);
for (int i = 0; i < sectorSize / 4; i++)
miniFat[mw++] = BinaryPrimitives.ReadUInt32LittleEndian(cfb.AsSpan(baseOff + i * 4));
}
var result = new byte[streamSize];
int written = 0;
uint miniCur = streamStart;
int miniGuard = 0;
int totalMini = miniStream.Length / MiniSectorSize;
while (miniCur != ENDOFCHAIN && miniCur != FREESECT && written < streamSize)
{
if (miniCur >= (uint)miniFat.Length || miniCur >= (uint)totalMini) return null;
if (miniGuard++ > totalMini + 1) return null;
int take = (int)Math.Min(MiniSectorSize, streamSize - written);
Array.Copy(miniStream, (int)miniCur * MiniSectorSize, result, written, take);
written += take;
miniCur = miniFat[miniCur];
}
return written == streamSize ? result : null;
}
catch
{
return null;
}
}
}
@@ -0,0 +1,47 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Text.RegularExpressions;
namespace OfficeCli.Core.Diagram;
/// <summary>
/// Single entry point for `add --type diagram`: sniffs the mermaid header and
/// dispatches to the matching layout engine, returning the shared geometric IR
/// (<see cref="LaidOutGraph"/>). Mermaid diagram types we don't render yet are
/// rejected with a clear message rather than producing garbage — the `diagram`
/// umbrella name stays honest.
/// </summary>
public static class DiagramCompiler
{
public static LaidOutGraph Compile(string mermaid)
{
var header = FirstMeaningfulLine(mermaid);
if (Regex.IsMatch(header, @"^(flowchart|graph)\b", RegexOptions.IgnoreCase))
return FlowchartLayout.Layout(MermaidParser.Parse(mermaid));
if (Regex.IsMatch(header, @"^sequenceDiagram\b", RegexOptions.IgnoreCase))
return SequenceLayout.Layout(SequenceLayout.Parse(mermaid));
// No explicit header → assume flowchart (mermaid's own lenient default).
if (header.Length == 0 || !Regex.IsMatch(header, @"^[A-Za-z]"))
return FlowchartLayout.Layout(MermaidParser.Parse(mermaid));
var kind = Regex.Match(header, @"^[A-Za-z]+").Value;
throw new ArgumentException(
$"diagram type '{kind}' is not supported yet (currently: flowchart, sequenceDiagram).");
}
private static string FirstMeaningfulLine(string text)
{
foreach (var raw in text.Split('\n'))
{
var s = raw.Trim();
if (s.Length > 0 && !s.StartsWith("%%"))
return s;
}
return "";
}
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Collections.Generic;
namespace OfficeCli.Core.Diagram;
/// <summary>Flowchart node shape kind (mermaid shape token → drawing preset).</summary>
public enum FlowShape
{
Process, // [text] rectangle
Decision, // {text} diamond
Terminator, // (text) rounded rectangle
Stadium, // ([text]) pill
Circle, // ((text)) ellipse
Hexagon, // {{text}} hexagon
Parallelogram, // [/text/] parallelogram
Database, // [(text)] cylinder
Subroutine, // [[text]] framed rectangle
Flag, // >text] asymmetric
}
public enum FlowDirection { TopDown, LeftRight }
// ---- Semantic IR (front-end boundary: logical graph, NO coordinates) --------
// This is what mermaid / draw.io / graphviz-dot all map onto. The layout engine
// consumes it; front-ends produce it. Serializable so `add diagram` can also
// accept an IR object instead of mermaid text.
public sealed class DiagramNode
{
public string Id = "";
public string Label = "";
public FlowShape Shape = FlowShape.Process;
}
public sealed class DiagramEdge
{
public string From = "";
public string To = "";
public string Label = "";
}
public sealed class DiagramGraph
{
/// <summary>Nodes in first-seen order (matched by the emitter's positional index).</summary>
public readonly List<DiagramNode> Nodes = new();
public readonly Dictionary<string, DiagramNode> NodeById = new();
public readonly List<DiagramEdge> Edges = new();
public FlowDirection Direction = FlowDirection.TopDown;
public DiagramNode GetOrAdd(string id)
{
if (!NodeById.TryGetValue(id, out var n))
{
n = new DiagramNode { Id = id, Label = id };
NodeById[id] = n;
Nodes.Add(n);
}
return n;
}
}
// ---- Geometric IR (back-end boundary: laid-out, coordinates in cm) ----------
// The layout engine produces it; any emitter (pptx / docx / svg) consumes it.
// draw.io input (which already carries coordinates) would enter at THIS layer,
// skipping the layout engine — that is the whole point of the two-IR split.
public readonly struct Pt
{
public readonly double X;
public readonly double Y;
public Pt(double x, double y) { X = x; Y = y; }
}
public sealed class PlacedNode
{
public string Id = "";
public string Label = "";
public FlowShape Shape;
public double X, Y, W, H; // cm, top-left + size
}
/// <summary>An edge as an orthogonal polyline; the emitter draws one straight
/// connector per segment and puts the arrowhead on the final segment.</summary>
public sealed class RoutedEdge
{
public List<Pt> Points = new();
public bool ArrowAtEnd = true;
public bool Dashed; // dashed stroke (sequence lifelines & return messages)
}
public sealed class EdgeLabel
{
public string Text = "";
public double Cx, Cy; // cm, center
// Flowchart labels sit ON the edge line and need an opaque (white) backing
// to mask it. Sequence-message labels sit ABOVE the arrow in empty space, so
// an opaque backing would only mask whatever lifeline it overlaps → set false.
public bool Opaque = true;
}
public sealed class LaidOutGraph
{
public readonly List<PlacedNode> Nodes = new();
public readonly List<RoutedEdge> Edges = new();
public readonly List<EdgeLabel> Labels = new();
public double SlideWidthCm;
public double SlideHeightCm;
public double FontScale = 1.0;
}
@@ -0,0 +1,35 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Collections.Generic;
namespace OfficeCli.Core.Diagram;
/// <summary>
/// Format-agnostic visual style for a laid-out diagram: the OOXML preset
/// geometry name + fill/line colors per <see cref="FlowShape"/>, plus the edge
/// color. Shared by the pptx and docx emitters so the two never drift (the
/// geometry strings — "rect", "diamond", "can", … — are valid DrawingML
/// <c>a:prstGeom@prst</c> values usable directly in docx and via
/// <c>TryParsePresetShape</c> in pptx).
/// </summary>
public static class DiagramStyles
{
public static readonly IReadOnlyDictionary<FlowShape, (string Geometry, string Fill, string Line)> ByShape =
new Dictionary<FlowShape, (string, string, string)>
{
[FlowShape.Process] = ("rect", "DAE8FC", "6C8EBF"),
[FlowShape.Decision] = ("diamond", "FFF2CC", "D6B656"),
[FlowShape.Terminator] = ("roundRect", "D5E8D4", "82B366"),
[FlowShape.Stadium] = ("roundRect", "D5E8D4", "82B366"),
[FlowShape.Circle] = ("ellipse", "F8CECC", "B85450"),
[FlowShape.Hexagon] = ("hexagon", "FFF2CC", "D6B656"),
[FlowShape.Parallelogram] = ("parallelogram", "DAE8FC", "6C8EBF"),
[FlowShape.Database] = ("can", "E1D5E7", "9673A6"),
[FlowShape.Subroutine] = ("rect", "DAE8FC", "6C8EBF"),
[FlowShape.Flag] = ("rect", "DAE8FC", "6C8EBF"),
};
/// <summary>Connector / edge stroke color (dark grey).</summary>
public const string EdgeColor = "4D4D4D";
}
@@ -0,0 +1,628 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Collections.Generic;
using System.Linq;
namespace OfficeCli.Core.Diagram;
/// <summary>
/// Layered (Sugiyama) flowchart layout: <see cref="DiagramGraph"/> (semantic IR)
/// → <see cref="LaidOutGraph"/> (geometric IR, coordinates in cm).
///
/// Pipeline (faithful port of the validated Python reference):
/// break cycles (DFS back-edge) → longest-path rank → dummy nodes on long edges
/// → barycenter crossing reduction → BrandesKöpf cross-axis coordinates
/// → fit-to-canvas (poster sizing) → self-computed orthogonal routing with
/// port distribution and track nudging → self-loops + parallel-label stagger.
///
/// All coordinates emitted in centimetres; the emitter converts cm → EMU.
/// </summary>
public static class FlowchartLayout
{
private const double VGap = 2.2, HGap = 1.4, EdgeSep = 0.8;
private sealed class WNode
{
public string Id = "";
public bool Dummy;
public string Label = "";
public FlowShape Shape;
public double X, Y, W, H;
public int Rank;
}
private sealed class WEdge
{
public string From = "", To = "", Label = "";
public bool Rev, Self;
public List<string> Wp = new(); // waypoint dummy ids, source→target order
// routing scratch
public string SSide = "", TSide = "";
public Pt SRef, TRef;
public double LabelDy;
}
public static LaidOutGraph Layout(DiagramGraph g)
{
// A header with no node/edge statements (e.g. a bare "flowchart TD", or
// input whose only line failed to parse into a node) leaves zero nodes.
// Guard here with a clear message — otherwise the bounding-box Min/Max
// below throws a bare "Sequence contains no elements" that surfaces as
// internal_error. Mirrors SequenceLayout's empty-participants guard.
if (g.Nodes.Count == 0)
throw new ArgumentException(
"diagram has no nodes — the mermaid source has no node/edge statements "
+ "(e.g. 'flowchart TD; A[Start] --> B[End]').");
bool td = g.Direction == FlowDirection.TopDown;
var nodes = new Dictionary<string, WNode>();
var real = new List<string>();
foreach (var dn in g.Nodes)
{
var n = new WNode { Id = dn.Id, Label = dn.Label, Shape = dn.Shape };
SizeNode(n);
nodes[dn.Id] = n;
real.Add(dn.Id);
}
var edges = new List<WEdge>();
foreach (var de in g.Edges)
{
if (!nodes.ContainsKey(de.From) || !nodes.ContainsKey(de.To)) continue;
edges.Add(new WEdge { From = de.From, To = de.To, Label = de.Label, Self = de.From == de.To });
}
// 3a. break cycles: DFS, mark back edges (to a node on the stack)
var adj = Group(edges.Where(e => !e.Self), e => e.From);
var state = new Dictionary<string, int>();
void Dfs(string u)
{
state[u] = 1;
if (adj.TryGetValue(u, out var outs))
foreach (var e in outs)
{
int s = state.GetValueOrDefault(e.To, 0);
if (s == 1) e.Rev = true;
else if (s == 0) Dfs(e.To);
}
state[u] = 2;
}
foreach (var i in real)
if (state.GetValueOrDefault(i, 0) == 0) Dfs(i);
(string top, string bot) Ends(WEdge e) => e.Rev ? (e.To, e.From) : (e.From, e.To);
// 3b. longest-path rank (over the DAG, back edges reversed)
var succ = new Dictionary<string, List<string>>();
var indeg = real.ToDictionary(i => i, _ => 0);
foreach (var e in edges.Where(e => !e.Self))
{
var (a, b) = Ends(e);
succ.TryAdd(a, new List<string>()); succ[a].Add(b);
indeg[b]++;
}
var rank = real.ToDictionary(i => i, _ => 0);
var ind = new Dictionary<string, int>(indeg);
var q = new Queue<string>(real.Where(i => ind[i] == 0));
if (q.Count == 0 && real.Count > 0) q.Enqueue(real[0]);
var seen = new HashSet<string>();
while (q.Count > 0)
{
var u = q.Dequeue();
if (!seen.Add(u)) continue;
if (succ.TryGetValue(u, out var vs))
foreach (var v in vs)
{
rank[v] = Math.Max(rank[v], rank[u] + 1);
if (--ind[v] <= 0) q.Enqueue(v);
}
}
int maxRank = rank.Count > 0 ? rank.Values.Max() : 0;
foreach (var i in real)
if (!seen.Contains(i)) rank[i] = maxRank;
// 3c. insert dummy nodes on edges spanning >1 rank
int dn2 = 0;
foreach (var e in edges.Where(e => !e.Self))
{
var (a, b) = Ends(e);
var chain = new List<string> { a };
for (int r = rank[a] + 1; r < rank[b]; r++)
{
var did = "__d" + dn2++;
nodes[did] = new WNode { Id = did, Dummy = true, W = 0.5, H = 0.5, Rank = r };
chain.Add(did);
}
chain.Add(b);
var wp = chain.GetRange(1, chain.Count - 2);
if (e.Rev) wp.Reverse();
e.Wp = wp;
}
foreach (var i in real) nodes[i].Rank = rank[i];
// expanded adjacency (real+dummy) from chains, for barycenter + BK
var esucc = new Dictionary<string, List<string>>();
var epred = new Dictionary<string, List<string>>();
foreach (var e in edges.Where(e => !e.Self))
{
var (a, b) = Ends(e);
var ch = new List<string> { a };
ch.AddRange(e.Rev ? Enumerable.Reverse(e.Wp) : e.Wp);
ch.Add(b);
for (int i = 0; i < ch.Count - 1; i++)
{
esucc.TryAdd(ch[i], new List<string>()); esucc[ch[i]].Add(ch[i + 1]);
epred.TryAdd(ch[i + 1], new List<string>()); epred[ch[i + 1]].Add(ch[i]);
}
}
List<string> Pred(string v) => epred.TryGetValue(v, out var l) ? l : new List<string>();
List<string> Succ(string v) => esucc.TryGetValue(v, out var l) ? l : new List<string>();
// ranks → ordered rows
var allIds = nodes.Keys.ToList();
var order = new SortedDictionary<int, List<string>>();
foreach (var i in allIds)
{
order.TryAdd(nodes[i].Rank, new List<string>());
order[nodes[i].Rank].Add(i);
}
// barycenter crossing reduction
var ranks = order.Keys.ToList();
Dictionary<string, int> PosIn(int r) =>
order.TryGetValue(r, out var row)
? row.Select((n, k) => (n, k)).ToDictionary(t => t.n, t => t.k)
: new Dictionary<string, int>();
for (int sweep = 0; sweep < 6; sweep++)
{
foreach (var r in ranks.Where(r => r != ranks[0]))
{
var p = PosIn(r - 1);
order[r] = order[r].OrderBy(n => Bary(Pred(n), p)).ToList();
}
foreach (var r in Enumerable.Reverse(ranks).Where(r => r != ranks[^1]))
{
var p = PosIn(r + 1);
order[r] = order[r].OrderBy(n => Bary(Succ(n), p)).ToList();
}
}
// LR edge labels sit ALONG the horizontal edge, so a label wider than the
// inter-rank gap would spill into the adjacent nodes and mask the arrowhead
// (TD labels are perpendicular to their vertical edge, so VGap already fits
// them). Reserve extra rank-axis room for the widest label crossing each gap.
var labelGap = new Dictionary<int, double>();
if (!td)
foreach (var e in edges.Where(e => !e.Self && !string.IsNullOrEmpty(e.Label)))
{
var (a, b) = Ends(e);
int gr = Math.Min(rank[a], rank[b]);
labelGap[gr] = Math.Max(labelGap.GetValueOrDefault(gr, 0), TextExtent(e.Label).w + 1.0);
}
// 3d. coordinates: rank-axis = cumulative depth; cross-axis = BrandesKöpf
var rankPos = new Dictionary<int, double>();
double acc = 0;
foreach (var r in ranks)
{
rankPos[r] = acc;
double span = order[r].Max(j => td ? nodes[j].H : nodes[j].W);
acc += span + (td ? VGap : HGap) + (td ? 0 : labelGap.GetValueOrDefault(r, 0));
}
var layers = ranks.Select(r => order[r]).ToList();
Func<string, double> csize = td ? (v => nodes[v].W) : (v => nodes[v].H);
var cross = BkPosition(layers, Pred, Succ, csize, HGap, EdgeSep, id => id.StartsWith("__d"));
foreach (var r in ranks)
foreach (var v in order[r])
{
double c = cross[v], d = rankPos[r];
if (td) { nodes[v].X = c - nodes[v].W / 2; nodes[v].Y = d; }
else { nodes[v].Y = c - nodes[v].H / 2; nodes[v].X = d; }
}
// 3e. fit-to-canvas (poster sizing): keep readable; only shrink past PowerPoint max
double minX = allIds.Min(i => nodes[i].X), minY = allIds.Min(i => nodes[i].Y);
double maxX = allIds.Max(i => nodes[i].X + nodes[i].W), maxY = allIds.Max(i => nodes[i].Y + nodes[i].H);
double bw = maxX - minX, bh = maxY - minY;
const double M = 1.2, MaxD = 55.0;
double sc = Math.Min(Math.Min(bw > 0 ? MaxD / bw : 1, bh > 0 ? MaxD / bh : 1), 1.0);
foreach (var i in allIds)
{
var n = nodes[i];
n.X = M + (n.X - minX) * sc; n.Y = M + (n.Y - minY) * sc;
n.W *= sc; n.H *= sc;
}
var outp = new LaidOutGraph
{
FontScale = sc,
SlideWidthCm = Math.Max(bw * sc + 2 * M, 12.0),
SlideHeightCm = Math.Max(bh * sc + 2 * M, 9.0),
};
foreach (var i in real)
{
var n = nodes[i];
outp.Nodes.Add(new PlacedNode { Id = n.Id, Label = n.Label, Shape = n.Shape, X = n.X, Y = n.Y, W = n.W, H = n.H });
}
Route(outp, nodes, edges, g, td);
return outp;
}
// ---- routing (produces the geometric IR's polylines + labels) -----------
private static void Route(LaidOutGraph outp, Dictionary<string, WNode> nodes, List<WEdge> edges,
DiagramGraph g, bool td)
{
Pt Center(string i) { var n = nodes[i]; return new Pt(n.X + n.W / 2, n.Y + n.H / 2); }
var valid = edges.Where(e => !e.Self && nodes.ContainsKey(e.From) && nodes.ContainsKey(e.To)).ToList();
// choose a source/target side by geometry, cache reference coordinate
foreach (var e in valid)
{
var (scx, scy) = (Center(e.From).X, Center(e.From).Y);
var (tcx, tcy) = (Center(e.To).X, Center(e.To).Y);
Pt first = e.Wp.Count > 0 ? Center(e.Wp[0]) : new Pt(tcx, tcy);
Pt last = e.Wp.Count > 0 ? Center(e.Wp[^1]) : new Pt(scx, scy);
if (td) { e.SSide = first.Y >= scy ? "b" : "t"; e.TSide = last.Y <= tcy ? "t" : "b"; }
else { e.SSide = first.X >= scx ? "r" : "l"; e.TSide = last.X <= tcx ? "l" : "r"; }
e.SRef = first; e.TRef = last;
}
Pt Attach(WNode node, string side, int k, int tot)
{
double frac = (k + 1.0) / (tot + 1.0);
if (side is "t" or "b")
return new Pt(node.X + node.W * frac, side == "t" ? node.Y : node.Y + node.H);
return new Pt(side == "l" ? node.X : node.X + node.W, node.Y + node.H * frac);
}
var srcPt = new Dictionary<WEdge, Pt>();
var tgtPt = new Dictionary<WEdge, Pt>();
// Distribute ports per (node, side) over BOTH the source ends and target ends
// that land there. Grouping source- and target-attachments separately (the
// old bug) gave each a lone-on-its-side centre port, so a node with one
// incoming AND one outgoing edge on the same side (every cycle: e.g. a state
// that is both entered and left on its left face) collided both at the centre,
// producing overlapping collinear segments. One combined group → distinct ports.
var reqs = new List<(WNode node, string side, WEdge e, bool src, Pt refp)>();
foreach (var e in valid)
{
reqs.Add((nodes[e.From], e.SSide, e, true, e.SRef));
reqs.Add((nodes[e.To], e.TSide, e, false, e.TRef));
}
foreach (var grp in reqs.GroupBy(r => (r.node.Id, r.side)))
{
bool horiz = grp.Key.side is "t" or "b"; // horizontal face → order along X
var rs = grp.OrderBy(r => horiz ? r.refp.X : r.refp.Y).ToList();
for (int k = 0; k < rs.Count; k++)
{
var pt = Attach(rs[k].node, rs[k].side, k, rs.Count);
if (rs[k].src) srcPt[rs[k].e] = pt; else tgtPt[rs[k].e] = pt;
}
}
// Pass A: build each edge's polyline; jog corners get a nudge-able coordinate
var routes = new List<List<Pt>>();
var jogs = new List<Jog>();
foreach (var e in valid)
{
var raw = new List<Pt> { srcPt[e] };
raw.AddRange(e.Wp.Select(Center));
raw.Add(tgtPt[e]);
var pts = new List<Pt> { raw[0] };
for (int i = 0; i < raw.Count - 1; i++)
{
double x1 = pts[^1].X, y1 = pts[^1].Y, x2 = raw[i + 1].X, y2 = raw[i + 1].Y;
if (Math.Abs(x1 - x2) < 0.05 || Math.Abs(y1 - y2) < 0.05)
{
pts.Add(new Pt(x2, y2));
}
else if (td)
{
double m = (y1 + y2) / 2;
int ci = pts.Count;
pts.Add(new Pt(x1, m)); pts.Add(new Pt(x2, m)); pts.Add(new Pt(x2, y2));
jogs.Add(new Jog { Axis = 'y', V = m, A = Math.Min(x1, x2), B = Math.Max(x1, x2), R = routes.Count, C1 = ci, C2 = ci + 1 });
}
else
{
double m = (x1 + x2) / 2;
int ci = pts.Count;
pts.Add(new Pt(m, y1)); pts.Add(new Pt(m, y2)); pts.Add(new Pt(x2, y2));
jogs.Add(new Jog { Axis = 'x', V = m, A = Math.Min(y1, y2), B = Math.Max(y1, y2), R = routes.Count, C1 = ci, C2 = ci + 1 });
}
}
routes.Add(pts);
}
// Pass B: nudge — split colliding (overlapping-span) jogs in a band onto tracks
foreach (var band in jogs.GroupBy(j => Math.Round(j.V / 0.8)))
{
var js = band.OrderBy(j => j.A).ThenBy(j => j.B).ToList();
var tracks = new List<List<(double A, double B)>>();
foreach (var j in js)
{
int placed = -1;
for (int ti = 0; ti < tracks.Count; ti++)
if (tracks[ti].All(s => j.B <= s.A || j.A >= s.B)) { tracks[ti].Add((j.A, j.B)); placed = ti; break; }
if (placed < 0) { tracks.Add(new List<(double, double)> { (j.A, j.B) }); placed = tracks.Count - 1; }
j.Track = placed;
}
int nt = tracks.Count;
if (nt <= 1) continue;
double baseV = js.Average(j => j.V);
foreach (var j in js)
{
double newV = baseV + (j.Track - (nt - 1) / 2.0) * 0.6;
var r = routes[j.R];
if (j.Axis == 'y') { r[j.C1] = new Pt(r[j.C1].X, newV); r[j.C2] = new Pt(r[j.C2].X, newV); }
else { r[j.C1] = new Pt(newV, r[j.C1].Y); r[j.C2] = new Pt(newV, r[j.C2].Y); }
}
}
// parallel edges (same from→to) stagger their labels so white masks don't collide
foreach (var grp in valid.GroupBy(e => (e.From, e.To)))
{
var es = grp.ToList();
for (int k = 0; k < es.Count; k++) es[k].LabelDy = es.Count > 1 ? k * 0.62 : 0.0;
}
// Pass C: geometric IR edges + labels
for (int ei = 0; ei < valid.Count; ei++)
{
var e = valid[ei];
outp.Edges.Add(new RoutedEdge { Points = routes[ei], ArrowAtEnd = true });
if (!string.IsNullOrEmpty(e.Label))
{
var p0 = routes[ei][0]; var p1 = routes[ei][1];
double dx = p1.X - p0.X, dy = p1.Y - p0.Y;
double slen = Math.Sqrt(dx * dx + dy * dy); if (slen == 0) slen = 1;
// Horizontal (LR) segment: centre the label in the reserved gap so its
// white mask clears both nodes. Otherwise hug the source — branch
// labels ("Yes"/"No") read best next to the node they leave.
double t = (Math.Abs(dy) < 0.05 && Math.Abs(dx) > 0.05)
? 0.5
: Math.Min(0.85, slen * 0.45) / slen;
outp.Labels.Add(new EdgeLabel { Text = e.Label, Cx = p0.X + dx * t, Cy = p0.Y + dy * t + e.LabelDy });
}
}
// self-loops: a small side loop with the arrowhead back into the node
foreach (var e in edges.Where(e => e.Self && nodes.ContainsKey(e.From)))
{
var n = nodes[e.From]; const double L = 1.0;
var pts = new List<Pt>();
Pt lp;
if (td)
{
double rx = n.X + n.W, a = n.Y + n.H * 0.3, b = n.Y + n.H * 0.7;
pts.Add(new Pt(rx, a)); pts.Add(new Pt(rx + L, a)); pts.Add(new Pt(rx + L, b)); pts.Add(new Pt(rx, b));
lp = new Pt(rx + L + 0.7, (a + b) / 2);
}
else
{
double by = n.Y + n.H, a = n.X + n.W * 0.3, b = n.X + n.W * 0.7;
pts.Add(new Pt(a, by)); pts.Add(new Pt(a, by + L)); pts.Add(new Pt(b, by + L)); pts.Add(new Pt(b, by));
lp = new Pt((a + b) / 2, by + L + 0.3);
}
outp.Edges.Add(new RoutedEdge { Points = pts, ArrowAtEnd = true });
if (!string.IsNullOrEmpty(e.Label))
outp.Labels.Add(new EdgeLabel { Text = e.Label, Cx = lp.X, Cy = lp.Y });
}
}
private sealed class Jog
{
public char Axis;
public double V, A, B;
public int R, C1, C2, Track;
}
// ---- BrandesKöpf cross-axis coordinate assignment (dagre port) ---------
private static Dictionary<string, double> BkPosition(
List<List<string>> layers, Func<string, List<string>> pred, Func<string, List<string>> succ,
Func<string, double> csize, double nodesep, double edgesep, Func<string, bool> isDummy)
{
var order = new Dictionary<string, int>();
foreach (var lyr in layers)
for (int i = 0; i < lyr.Count; i++) order[lyr[i]] = i;
var conflicts = new HashSet<(string, string)>();
void AddC(string v, string w) => conflicts.Add(string.CompareOrdinal(v, w) < 0 ? (v, w) : (w, v));
bool HasC(string v, string w) => conflicts.Contains(string.CompareOrdinal(v, w) < 0 ? (v, w) : (w, v));
string? Inner(string v)
{
if (isDummy(v))
foreach (var u in pred(v))
if (isDummy(u)) return u;
return null;
}
for (int li = 1; li < layers.Count; li++)
{
var prev = layers[li - 1]; var lyr = layers[li];
int k0 = 0, scanPos = 0, plen = prev.Count;
string? last = lyr.Count > 0 ? lyr[^1] : null;
for (int i = 0; i < lyr.Count; i++)
{
var v = lyr[i];
var w = Inner(v);
int k1 = w != null ? order[w] : plen;
if (w != null || v == last)
{
for (int s = scanPos; s <= i; s++)
{
var sn = lyr[s];
foreach (var u in pred(sn))
{
int up = order[u];
if ((up < k0 || k1 < up) && !(isDummy(u) && isDummy(sn))) AddC(u, sn);
}
}
scanPos = i + 1; k0 = k1;
}
}
}
(Dictionary<string, string> root, Dictionary<string, string> align) VAlign(
List<List<string>> lays, Func<string, List<string>> neigh)
{
var root = new Dictionary<string, string>();
var align = new Dictionary<string, string>();
var pos = new Dictionary<string, int>();
foreach (var lyr in lays)
for (int o = 0; o < lyr.Count; o++) { root[lyr[o]] = lyr[o]; align[lyr[o]] = lyr[o]; pos[lyr[o]] = o; }
foreach (var lyr in lays)
{
int prevIdx = -1;
foreach (var v in lyr)
{
var ws = neigh(v).OrderBy(a => pos.GetValueOrDefault(a, 0)).ToList();
if (ws.Count == 0) continue;
double mp = (ws.Count - 1) / 2.0;
for (int i = (int)Math.Floor(mp); i <= (int)Math.Ceiling(mp); i++)
{
var w = ws[i];
if (align[v] == v && prevIdx < pos[w] && !HasC(v, w))
{
align[w] = v; align[v] = root[v] = root[w]; prevIdx = pos[w];
}
}
}
}
return (root, align);
}
double Sep(string v, string u) =>
(csize(v) + csize(u)) / 2
+ (isDummy(v) ? edgesep : nodesep) / 2 + (isDummy(u) ? edgesep : nodesep) / 2;
Dictionary<string, double> Compact(List<List<string>> lays, Dictionary<string, string> root, Dictionary<string, string> align)
{
var bnodes = new HashSet<string>();
var bedge = new Dictionary<(string, string), double>();
foreach (var lyr in lays)
{
string? u = null;
foreach (var v in lyr)
{
var rv = root[v]; bnodes.Add(rv);
if (u != null)
{
var ru = root[u]; var key = (ru, rv);
bedge[key] = Math.Max(Sep(v, u), bedge.GetValueOrDefault(key, 0));
}
u = v;
}
}
var bin = new Dictionary<string, List<(string, double)>>();
var bout = new Dictionary<string, List<(string, double)>>();
foreach (var kv in bedge)
{
var (a, b) = kv.Key;
bout.TryAdd(a, new()); bout[a].Add((b, kv.Value));
bin.TryAdd(b, new()); bin[b].Add((a, kv.Value));
}
var xs = new Dictionary<string, double>();
void Iterate(Action<string> setx, Func<string, List<(string, double)>> next)
{
var stack = new Stack<string>(bnodes);
var vis = new HashSet<string>();
while (stack.Count > 0)
{
var e = stack.Pop();
if (vis.Contains(e)) setx(e);
else { vis.Add(e); stack.Push(e); foreach (var (nx, _) in next(e)) stack.Push(nx); }
}
}
List<(string, double)> In(string e) => bin.TryGetValue(e, out var l) ? l : new();
List<(string, double)> Out(string e) => bout.TryGetValue(e, out var l) ? l : new();
Iterate(e => xs[e] = In(e).Count == 0 ? 0 : In(e).Max(t => xs.GetValueOrDefault(t.Item1, 0) + t.Item2), In);
Iterate(e =>
{
var outs = Out(e);
if (outs.Count > 0) xs[e] = Math.Max(xs.GetValueOrDefault(e, 0), outs.Min(t => xs.GetValueOrDefault(t.Item1, 0) - t.Item2));
}, Out);
var res = new Dictionary<string, double>();
foreach (var v in align.Keys) res[v] = xs.GetValueOrDefault(root[v], 0);
return res;
}
var xss = new Dictionary<string, Dictionary<string, double>>();
foreach (var vert in new[] { "u", "d" })
{
var baseL = vert == "u" ? layers : Enumerable.Reverse(layers).ToList();
foreach (var horiz in new[] { "l", "r" })
{
var lays = horiz == "r"
? baseL.Select(l => Enumerable.Reverse(l).ToList()).ToList()
: baseL.Select(l => l.ToList()).ToList();
Func<string, List<string>> neigh = vert == "u" ? pred : succ;
var (root, align) = VAlign(lays, neigh);
var xs = Compact(lays, root, align);
if (horiz == "r") xs = xs.ToDictionary(k => k.Key, k => -k.Value);
xss[vert + horiz] = xs;
}
}
double Width(Dictionary<string, double> xs)
{
double mx = double.NegativeInfinity, mn = double.PositiveInfinity;
foreach (var (v, x) in xs) { mx = Math.Max(mx, x + csize(v) / 2); mn = Math.Min(mn, x - csize(v) / 2); }
return mx - mn;
}
var small = xss.Values.OrderBy(Width).First();
double smin = small.Values.Min(), smax = small.Values.Max();
foreach (var key in xss.Keys.ToList())
{
var xs = xss[key];
if (ReferenceEquals(xs, small)) continue;
double delta = key[1] == 'l' ? smin - xs.Values.Min() : smax - xs.Values.Max();
if (delta != 0) xss[key] = xs.ToDictionary(k => k.Key, k => k.Value + delta);
}
var outd = new Dictionary<string, double>();
foreach (var v in xss["ul"].Keys)
{
var vals = new[] { xss["ul"][v], xss["ur"][v], xss["dl"][v], xss["dr"][v] };
Array.Sort(vals);
outd[v] = (vals[1] + vals[2]) / 2;
}
return outd;
}
// ---- sizing -------------------------------------------------------------
private static (double w, int lines) TextExtent(string label)
{
double w = 0;
foreach (var c in label) w += c > 0x2E80 ? 0.58 : 0.30;
const double maxLine = 5.0;
int lines = Math.Max(1, (int)(w / maxLine) + (w % maxLine != 0 ? 1 : 0));
return (Math.Min(w, maxLine), lines);
}
private static void SizeNode(WNode n)
{
var (tw, lines) = TextExtent(n.Label);
double w = tw + 1.0, h = 0.7 + lines * 0.62;
switch (n.Shape)
{
case FlowShape.Decision: w = tw * 2.2 + 1.0; h = Math.Max(lines * 1.24 + 0.9, 2.2); break;
case FlowShape.Hexagon: w = tw + 1.8; h = Math.Max(lines * 0.62 + 0.9, 1.4); break;
case FlowShape.Parallelogram: w = tw + 1.8; break;
case FlowShape.Database: h += 0.7; break;
case FlowShape.Circle: { double s = Math.Max(w, h) * 1.35; w = s; h = s; break; }
}
n.W = Math.Max(w, 2.4); n.H = Math.Max(h, 1.1);
}
private static double Bary(List<string> ns, Dictionary<string, int> pos) =>
ns.Count == 0 ? 1e9 : ns.Average(x => (double)pos.GetValueOrDefault(x, 0));
private static Dictionary<string, List<WEdge>> Group(IEnumerable<WEdge> es, Func<WEdge, string> key)
{
var d = new Dictionary<string, List<WEdge>>();
foreach (var e in es) { var k = key(e); d.TryAdd(k, new()); d[k].Add(e); }
return d;
}
}
@@ -0,0 +1,382 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Diagnostics;
using System.IO;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
namespace OfficeCli.Core.Diagram;
/// <summary>
/// Optional high-fidelity path for the <c>diagram</c> element: render the mermaid
/// source with the <b>real mermaid.js</b> to a PNG — covering every mermaid diagram
/// type (gantt / pie / class / state / er / git / mindmap / …) that the native
/// shape synthesizer does not, at full fidelity.
///
/// <para>Backend cascade, best tool first:
/// <list type="number">
/// <item><b>mmdc</b> (the official mermaid-cli), if installed — purpose-built,
/// one call to a tight PNG.</item>
/// <item><b>Chrome-family</b> browser the user already has (via
/// <see cref="HtmlScreenshot"/>): render mermaid.js in a page and screenshot it.
/// Only mermaid.min.js (~3.5 MB) is fetched to a local cache on first use
/// (mirror → CDN); if that fails the page loads mermaid from the CDN live.</item>
/// <item>otherwise the caller falls back to the native synthesizer
/// (<see cref="DiagramCompiler"/>) — zero dependencies, fully editable shapes.</item>
/// </list>
/// PNG (not SVG) throughout: Office cannot draw mermaid's <c>&lt;foreignObject&gt;</c>
/// HTML labels, so a raster that bakes in the browser's own rendering is required.</para>
/// </summary>
/// <summary>
/// The mermaid source is syntactically invalid (as opposed to an infrastructure
/// failure — missing browser, mermaid.js download, screenshot crash). Derives from
/// <see cref="ArgumentException"/> so the CLI surfaces it as a bad-input error
/// (<c>success:false</c> with the message) rather than a process failure, and so the
/// diagram Add path does NOT fall back to the native synthesizer — every backend
/// rejects the same broken source, and the point is to feed the parse error (with its
/// line number) back so the caller can fix it.
/// </summary>
public sealed class MermaidSyntaxException : ArgumentException
{
public MermaidSyntaxException(string message) : base(message) { }
}
public static class MermaidImageRenderer
{
// Pin a major version so cache + mirror + CDN agree and rendering is stable.
private const string MermaidVersion = "11";
// Own mirror first (offline-first, no third-party dependency at steady state),
// then the public CDN as a fallback.
private const string MirrorUrl =
"https://d.officecli.ai/assets/mermaid-" + MermaidVersion + ".min.js";
private const string CdnUrl =
"https://cdn.jsdelivr.net/npm/mermaid@" + MermaidVersion + "/dist/mermaid.min.js";
/// <summary>Sentinel prefix stamped into the rendered picture's alt-text so the
/// mermaid source travels inside the document and the diagram stays regenerable.</summary>
public const string SourceTag = "mermaid:";
private static string CacheDir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".officecli", "cache");
private static string CachedJsPath => Path.Combine(CacheDir, $"mermaid-{MermaidVersion}.min.js");
/// <summary>True when any image backend is available: mmdc, or a chrome-family browser.</summary>
public static bool IsAvailable() => TryLocateMmdc(out _) || HtmlScreenshot.HasChromeFamily();
/// <summary>
/// Daily-refresh hook, called from <see cref="UpdateChecker"/>'s once-per-24h
/// background process (already talking to the mirror). Revalidates an <b>already
/// cached</b> mermaid.js against the mirror with a conditional request and updates
/// it if the server's copy changed. Never pre-downloads (first-use owns that),
/// never blocks, never throws. Only the chrome backend uses this cache; mmdc ships
/// its own mermaid.
/// </summary>
public static void RefreshCacheIfPresent()
{
try
{
if (!File.Exists(CachedJsPath)) return; // refresh only what the user actually uses
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
using var req = new HttpRequestMessage(HttpMethod.Get, MirrorUrl);
req.Headers.IfModifiedSince = new DateTimeOffset(File.GetLastWriteTimeUtc(CachedJsPath));
using var resp = http.SendAsync(req).GetAwaiter().GetResult();
if (resp.StatusCode == System.Net.HttpStatusCode.NotModified) return; // unchanged → keep cache
if (!resp.IsSuccessStatusCode) return; // mirror hiccup → keep cache
var bytes = resp.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult();
if (bytes.Length > 500_000)
File.WriteAllBytes(CachedJsPath, bytes);
}
catch { /* best effort — the existing cache stays usable */ }
}
/// <summary>
/// Render <paramref name="mermaid"/> to a temporary PNG file and return its path
/// (caller owns + deletes it). Tries mmdc first (purpose-built, one call), then a
/// chrome-family browser; degrades between them so a broken mmdc still yields a
/// render. Throws <see cref="InvalidOperationException"/> only when no backend
/// works (message carries the underlying tool's error).
/// </summary>
public static string RenderToPngFile(string mermaid)
{
Exception? failure = null;
if (TryLocateMmdc(out var mmdc))
{
try { return RenderViaMmdc(mermaid, mmdc); }
catch (MermaidSyntaxException) { throw; } // bad input — the browser would reject it too
catch (Exception e) { failure = e; }
}
if (HtmlScreenshot.HasChromeFamily())
{
try { return RenderViaChrome(mermaid); }
catch (MermaidSyntaxException) { throw; } // surface the parse error, don't mask it
catch (Exception e) { failure ??= e; }
}
throw failure ?? new InvalidOperationException(
"render=image needs mermaid-cli (mmdc) or a headless browser (Chrome/Chromium/Edge). "
+ "Install one, or use render=native for the built-in synthesizer.");
}
// ----- mmdc (official mermaid-cli) --------------------------------------------------
private static string? _mmdcExe;
private static bool _mmdcProbed;
/// <summary>Locate mmdc: OFFICECLI_MMDC (explicit path) wins, else <c>mmdc</c> on PATH.</summary>
private static bool TryLocateMmdc(out string exe)
{
if (!_mmdcProbed)
{
_mmdcProbed = true;
_mmdcExe = ProbeMmdc();
}
exe = _mmdcExe ?? "";
return _mmdcExe != null;
}
/// <summary>Heuristic: does mmdc's stderr/stdout describe a source syntax problem
/// (vs a crash / environment fault)? mmdc surfaces mermaid's own parser text.</summary>
private static bool LooksLikeSyntaxError(string msg)
{
if (string.IsNullOrEmpty(msg)) return false;
return msg.Contains("Parse error", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Lexical error", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("No diagram type detected", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("UnknownDiagramError", StringComparison.OrdinalIgnoreCase)
|| msg.Contains("Expecting ", StringComparison.Ordinal);
}
private static string? ProbeMmdc()
{
// OFFICECLI_MMDC (explicit path) wins; otherwise find `mmdc` on PATH via the
// same shared lookup used for chrome/playwright (WhichFirst handles PATHEXT,
// so "mmdc" resolves mmdc.cmd on Windows).
var env = Environment.GetEnvironmentVariable("OFFICECLI_MMDC");
if (!string.IsNullOrWhiteSpace(env) && File.Exists(env)) return env;
return HtmlScreenshot.Which("mmdc");
}
private static string RenderViaMmdc(string mermaid, string exe)
{
var inPath = Path.Combine(Path.GetTempPath(), $"ocli_mmd_{Guid.NewGuid():N}.mmd");
var outPath = Path.ChangeExtension(inPath, ".png");
File.WriteAllText(inPath, mermaid);
try
{
var psi = new ProcessStartInfo(exe)
{
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(inPath);
psi.ArgumentList.Add("-o"); psi.ArgumentList.Add(outPath);
psi.ArgumentList.Add("-b"); psi.ArgumentList.Add("transparent");
psi.ArgumentList.Add("-s"); psi.ArgumentList.Add("2"); // HiDPI for crisp raster
var pcfg = Environment.GetEnvironmentVariable("OFFICECLI_MMDC_PUPPETEER");
if (!string.IsNullOrWhiteSpace(pcfg) && File.Exists(pcfg))
{
psi.ArgumentList.Add("-p"); psi.ArgumentList.Add(pcfg);
}
using var p = Process.Start(psi)
?? throw new InvalidOperationException("failed to start mmdc.");
// Async-drain both streams: the serial stderr-then-stdout reads
// interlocked when mmdc filled the stdout pipe first (bounded by
// the 120s kill below, but a wasted two minutes per diagram).
var errTask = p.StandardError.ReadToEndAsync();
var outTask = p.StandardOutput.ReadToEndAsync();
if (!p.WaitForExit(120_000))
{
try { p.Kill(true); } catch { /* best effort */ }
throw new InvalidOperationException("mmdc timed out after 120s.");
}
if (p.ExitCode != 0 || !File.Exists(outPath))
{
var msg = $"{errTask.Result}{outTask.Result}".Trim();
// A parse/unknown-type failure is bad input, not a broken mmdc; class
// it as syntax so the Add path surfaces it (and does not fall back).
if (LooksLikeSyntaxError(msg))
throw new MermaidSyntaxException(
$"mermaid syntax error: {msg} "
+ "(fix the mermaid source, or use render=native for the built-in subset).");
throw new InvalidOperationException($"mmdc failed (exit {p.ExitCode}). {msg}".Trim());
}
return outPath;
}
finally { try { File.Delete(inPath); } catch { /* best effort */ } }
}
// ----- chrome-family browser (mermaid.js in a page → sized screenshot) --------------
/// <summary>Two chrome passes: dump the DOM to read the diagram's viewBox, then
/// screenshot at exactly that size (HiDPI). PNG bakes in the browser's rendering
/// so mermaid's foreignObject labels — invisible to Office as SVG — appear.</summary>
private static string RenderViaChrome(string mermaid)
{
var jsRef = ResolveMermaidJsRef();
var html = BuildHtml(mermaid, jsRef);
var htmlPath = Path.Combine(Path.GetTempPath(), $"ocli_mmd_{Guid.NewGuid():N}.html");
File.WriteAllText(htmlPath, html);
try
{
var dom = HtmlScreenshot.DumpDom(htmlPath)
?? throw new InvalidOperationException("headless browser produced no output.");
// The <title> is the AUTHORITATIVE outcome — not the presence of an <svg>.
// On a syntax error mermaid.parse() rejects (we capture the message) but
// STILL injects its red "Syntax error" bomb graphic into the DOM anyway
// (suppressErrorRendering doesn't stop it). So a viewBox is present even on
// failure; keying off the svg would screenshot the bomb and "succeed".
// Trust the title: MMDREADY = real render, MMDSYNTAX = bad input, else infra.
if (dom.Contains("<title>MMDSYNTAX</title>", StringComparison.Ordinal))
throw new MermaidSyntaxException(
"mermaid syntax error: " + ExtractMermaidMessage(dom)
+ "\n(fix the mermaid source, or use render=native for the built-in subset).");
if (!dom.Contains("<title>MMDREADY</title>", StringComparison.Ordinal))
throw new InvalidOperationException(
dom.Contains("<title>MMDERR</title>", StringComparison.Ordinal)
? "mermaid failed to render: " + ExtractMermaidMessage(dom)
: "mermaid produced no diagram (mermaid.js failed to load or the render timed out).");
var (w, h) = ParseSvgSize(dom);
if (w <= 0 || h <= 0)
throw new InvalidOperationException("mermaid rendered but produced no measurable svg viewBox.");
var pngPath = Path.ChangeExtension(htmlPath, ".png");
if (!HtmlScreenshot.CaptureChromeSized(htmlPath, pngPath,
(int)Math.Ceiling(w) + 2, (int)Math.Ceiling(h) + 2))
throw new InvalidOperationException("headless screenshot failed.");
return pngPath;
}
finally { try { File.Delete(htmlPath); } catch { /* best effort */ } }
}
/// <summary>Cache → one-time download → live CDN. Returns a URL usable as a
/// &lt;script src&gt; (a <c>file://</c> for a cached/downloaded copy, else the CDN).</summary>
private static string ResolveMermaidJsRef()
{
try
{
if (File.Exists(CachedJsPath) && new FileInfo(CachedJsPath).Length > 500_000)
return new Uri(CachedJsPath).AbsoluteUri;
Directory.CreateDirectory(CacheDir);
foreach (var url in new[] { MirrorUrl, CdnUrl }) // mirror first, CDN fallback
{
try
{
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
var bytes = http.GetByteArrayAsync(url).GetAwaiter().GetResult();
if (bytes.Length > 500_000)
{
File.WriteAllBytes(CachedJsPath, bytes);
return new Uri(CachedJsPath).AbsoluteUri;
}
}
catch { /* try next source */ }
}
}
catch { /* fall through to live CDN */ }
return CdnUrl; // every download failed → reference the CDN directly in the page
}
private static string BuildHtml(string mermaid, string jsRef)
{
// Pass the source as base64 so no mermaid character can break out of the
// HTML/JS context. Render explicitly (startOnLoad:false + mermaid.run) and
// stamp the title so failures are recoverable from the dumped DOM.
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(mermaid));
return
"<!DOCTYPE html><html><head><meta charset=\"utf-8\">"
// svg{display:block}: an inline <svg> sits on the text baseline, leaving a
// descender gap BELOW it inside the inline-block wrapper. The fixed-height
// screenshot window then clips those few pixels → the diagram's bottom edge
// (e.g. a sequence diagram's bottom actor boxes) gets cut. block removes it.
+ "<style>html,body{margin:0;padding:0;background:transparent;font-size:0}"
+ "#d{display:inline-block}#d svg{display:block}</style>"
+ $"<script src=\"{jsRef}\"></script></head>"
// Hidden sink for a failure message. mermaid's parse error is multi-line
// with an aligned caret ('^') under the offending column; document.title
// would flatten the newlines and misalign the caret, so carry the verbatim
// message here (a <pre> preserves whitespace) and use the title only as the
// one-word outcome SIGNAL (MMDREADY / MMDSYNTAX / MMDERR).
+ "<body><pre id=\"mmderr\" style=\"display:none\"></pre>"
+ "<div id=\"d\" class=\"mermaid\"></div><script>"
// atob yields a BYTE string (one Latin-1 char per byte); decode those
// bytes back as UTF-8 so CJK/emoji in the mermaid source survive. A bare
// atob() would render "提交" as mojibake ("搤").
+ $"const src=new TextDecoder().decode(Uint8Array.from(atob(\"{b64}\"),c=>c.charCodeAt(0)));"
+ "document.getElementById('d').textContent=src;"
+ "window.addEventListener('load',async()=>{try{"
// htmlLabels:false → mermaid emits real SVG <text> instead of <foreignObject>
// (HTML), which Office's SVG renderer cannot display — otherwise every
// node/label comes out blank. securityLevel:loose allows the run.
// suppressErrorRendering: on invalid syntax mermaid otherwise silently
// renders a red "Syntax error in text" bomb graphic and returns success —
// we would screenshot the bomb and embed it. With this off, run() throws.
+ "mermaid.initialize({startOnLoad:false,securityLevel:'loose',htmlLabels:false,"
+ "flowchart:{htmlLabels:false},class:{htmlLabels:false},suppressErrorRendering:true});"
// Validate first: mermaid.parse() throws a precise, line-numbered error
// ('Parse error on line N: … Expecting X, got Y') without touching the DOM.
// Stamp it under a DISTINCT title so the host tells a user syntax error
// (surface it, let the agent fix the source) apart from an infra failure
// (browser/mermaid.js problem — fall back to the native synthesizer).
+ "try{await mermaid.parse(src);}catch(pe){"
+ "document.getElementById('mmderr').textContent=(pe&&pe.message?pe.message:String(pe));"
+ "document.title='MMDSYNTAX';return;}"
+ "await mermaid.run({nodes:[document.getElementById('d')]});"
// Tighten the SVG to its REAL content bounds. mermaid's own viewBox
// overshoots for some types (sequence diagrams reserve far more width/
// height than they draw), which otherwise bakes a big transparent band
// into the screenshot. getBBox() is the true rendered geometry; rewrite
// viewBox + width/height to it (+small pad) so the capture is a tight crop.
// pad clears ink that getBBox ignores: getBBox returns pure geometry, but
// mermaid drop-shadows (filter:drop-shadow(3px 5px 2px …)) paint several px
// past it — a 4px pad clipped the bottom actor boxes of a sequence diagram.
// 14 covers the largest shadow (offset 5 + blur 2) with margin to spare.
+ "try{const s=document.querySelector('#d svg');if(s){const b=s.getBBox();"
+ "const p=14,x=b.x-p,y=b.y-p,w=Math.ceil(b.width+2*p),h=Math.ceil(b.height+2*p);"
+ "s.setAttribute('viewBox',x+' '+y+' '+w+' '+h);"
+ "s.setAttribute('width',w);s.setAttribute('height',h);"
+ "s.style.maxWidth=w+'px';s.style.width=w+'px';s.style.height=h+'px';}}catch(e){}"
+ "document.title='MMDREADY';"
+ "}catch(e){document.getElementById('mmderr').textContent="
+ "(e&&e.message?e.message:String(e));document.title='MMDERR';}});"
+ "</script></body></html>";
}
/// <summary>Pull the verbatim failure message out of the hidden &lt;pre id="mmderr"&gt;
/// in the dumped DOM. The &lt;pre&gt; preserves mermaid's newlines (so its caret '^'
/// stays aligned under the offending column); the browser HTML-escapes the text, so
/// undo the common entities WITHOUT collapsing whitespace. Falls back to a generic
/// note if the sink is missing.</summary>
private static string ExtractMermaidMessage(string dom)
{
var m = Regex.Match(dom, "<pre id=\"mmderr\"[^>]*>(.*?)</pre>", RegexOptions.Singleline);
if (!m.Success || string.IsNullOrWhiteSpace(m.Groups[1].Value))
return "(no detail reported)";
var s = m.Groups[1].Value
.Replace("&amp;", "&").Replace("&lt;", "<").Replace("&gt;", ">")
.Replace("&quot;", "\"").Replace("&#39;", "'");
return s.Trim('\n', '\r', ' ', '\t');
}
/// <summary>Read the rendered diagram's CSS-pixel size from the svg viewBox in
/// the dumped DOM (mermaid writes <c>viewBox="0 0 W H"</c>). (0,0) if not found.</summary>
private static (double w, double h) ParseSvgSize(string dom)
{
var m = Regex.Match(dom, @"<svg[^>]*\bviewBox=""[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)""",
RegexOptions.IgnoreCase);
if (m.Success
&& double.TryParse(m.Groups[1].Value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var w)
&& double.TryParse(m.Groups[2].Value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var h))
return (w, h);
return (0, 0);
}
}
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace OfficeCli.Core.Diagram;
/// <summary>
/// Mermaid flowchart-subset parser: text → <see cref="DiagramGraph"/> (semantic IR).
///
/// Handles the common real-world syntax that shows up in the wild:
/// direction flowchart TD|TB|LR|RL|BT
/// node shapes [rect] {diamond} (round) ([stadium]) ((circle)) {{hexagon}}
/// [/parallelogram/] [\trapezoid\] [(database)] [[subroutine]] &gt;flag]
/// edges A --&gt; B --&gt; C (chained), A ---|no arrow|, -.-&gt; ==&gt; --o --x &lt;--&gt;
/// A -- text --&gt; B (mid-text label), A --&gt;|text| B (pipe label)
/// A &amp; B --&gt; C &amp; D (group expansion)
/// ignored subgraph/end/direction/style/class/classDef/linkStyle/click, %% comments
///
/// Unknown tokens degrade to null (edge dropped) — the parser never throws.
/// </summary>
public static class MermaidParser
{
// Node identifiers accept Unicode letters/digits, not just ASCII — mermaid
// itself allows them, and CJK / accented ids (开始, 判断, café) are common in
// non-English flowcharts. \p{L} = any letter, \p{N} = any digit. Without
// this a fully-Chinese flowchart parses to zero nodes ("diagram has no
// nodes"). The ASCII-only edge-id / class-attach patterns below are left as
// they were — those name mermaid-internal constructs, not user labels.
private const string Id = @"([\p{L}\p{N}_]+)";
// node-shape wrappers, most-specific first
private static readonly (FlowShape Shape, Regex Pat)[] ShapePats =
{
(FlowShape.Stadium, new Regex(@"^" + Id + @"\(\[(.*)\]\)$")),
(FlowShape.Subroutine, new Regex(@"^" + Id + @"\[\[(.*)\]\]$")),
(FlowShape.Database, new Regex(@"^" + Id + @"\[\((.*)\)\]$")),
(FlowShape.Circle, new Regex(@"^" + Id + @"\(\((.*)\)\)$")),
(FlowShape.Hexagon, new Regex(@"^" + Id + @"\{\{(.*)\}\}$")),
(FlowShape.Decision, new Regex(@"^" + Id + @"\{(.*)\}$")),
(FlowShape.Parallelogram, new Regex(@"^" + Id + @"\[/(.*)/\]$")),
(FlowShape.Parallelogram, new Regex(@"^" + Id + @"\[\\(.*)\\\]$")),
(FlowShape.Flag, new Regex(@"^" + Id + @">(.*)\]$")),
(FlowShape.Terminator, new Regex(@"^" + Id + @"\((.*)\)$")),
(FlowShape.Process, new Regex(@"^" + Id + @"\[(.*)\]$")),
};
private static readonly Regex Bare = new(@"^" + Id + @"$");
// link operator: optional leading '<', a run (>=2) of -/./=, optional head -/>/o/x, optional |label|
private static readonly Regex Link = new(@"\s*(<?[-.=]{2,}[-.=]*[->oxX]?)\s*(?:\|([^|]*)\|)?\s*");
// fold `A -- text --> B` (mid-text label) into pipe form `A -->|text| B`
private static readonly Regex MidText = new(@"([-.=]{2,})\s+([^\-.=>|][^>|]*?)\s+([-.=]{2,}[->oxX])");
private static readonly Regex Directive =
new(@"^(subgraph|end|direction|click|style|classDef|class|linkStyle)\b", RegexOptions.IgnoreCase);
private static readonly Regex DirHeader =
new(@"^(?:flowchart|graph)\s+(TD|TB|LR|RL|BT)\b", RegexOptions.IgnoreCase);
private static readonly Regex Header = new(@"^(?:flowchart|graph)\b", RegexOptions.IgnoreCase);
private static readonly Regex EdgeId = new(@"^[A-Za-z0-9_]+@");
// trailing class attach `A[label]:::className` — a style hook, not part of the id/shape
private static readonly Regex ClassAttach = new(@":::[A-Za-z0-9_]+\s*$");
public static DiagramGraph Parse(string text)
{
var g = new DiagramGraph();
foreach (var line in Statements(text))
{
var s = line.Trim();
if (s.Length == 0 || s.StartsWith("%%"))
continue;
var md = DirHeader.Match(s);
if (md.Success)
{
var d = md.Groups[1].Value.ToUpperInvariant();
g.Direction = (d == "LR" || d == "RL") ? FlowDirection.LeftRight : FlowDirection.TopDown;
continue;
}
if (Header.IsMatch(s) || Directive.IsMatch(s))
continue; // header / subgraph / style → no garbage nodes
s = MidText.Replace(s, "$3|$2|");
var links = Link.Matches(s);
if (links.Count == 0)
{
ParseNodeToken(s, g); // standalone node declaration
continue;
}
var parts = new List<string>();
var labels = new List<string>();
int prev = 0;
foreach (Match m in links)
{
parts.Add(s.Substring(prev, m.Index - prev));
labels.Add(m.Groups[2].Success ? m.Groups[2].Value : "");
prev = m.Index + m.Length;
}
parts.Add(s.Substring(prev));
var groups = new List<List<string>>();
foreach (var p in parts)
groups.Add(Group(p, g));
for (int i = 0; i < groups.Count - 1; i++)
{
var lbl = labels[i].Trim();
foreach (var a in groups[i])
foreach (var b in groups[i + 1])
g.Edges.Add(new DiagramEdge { From = a, To = b, Label = lbl });
}
}
return g;
}
private static IEnumerable<string> Statements(string text)
{
foreach (var raw in text.Split('\n'))
foreach (var stmt in raw.Split(';'))
yield return stmt;
}
/// <summary>Parse a single node token; register/update it. Returns id, or null if unparseable.</summary>
private static string? ParseNodeToken(string tok, DiagramGraph g)
{
tok = tok.Trim();
tok = EdgeId.Replace(tok, "").Trim(); // drop leading edge-id (e1@--> )
tok = ClassAttach.Replace(tok, "").Trim(); // drop trailing :::className (styling, ignored)
if (tok.Length == 0)
return null;
foreach (var (shape, pat) in ShapePats)
{
var m = pat.Match(tok);
if (!m.Success)
continue;
var id = m.Groups[1].Value;
var lbl = m.Groups[2].Value.Trim().Trim('"').Trim('\'');
if (lbl.Length == 0)
lbl = id;
var n = g.GetOrAdd(id);
n.Label = lbl;
n.Shape = shape;
return id;
}
var mb = Bare.Match(tok);
if (mb.Success)
{
var id = mb.Groups[1].Value;
g.GetOrAdd(id);
return id;
}
return null; // unparseable → skip, never throw
}
/// <summary>Expand an `A &amp; B` group into node ids (bracket-aware, so `&amp;` inside a label is safe).</summary>
private static List<string> Group(string token, DiagramGraph g)
{
var ids = new List<string>();
foreach (var t in SplitTop(token, '&'))
{
var id = ParseNodeToken(t, g);
if (id != null)
ids.Add(id);
}
return ids;
}
/// <summary>Split on <paramref name="sep"/> only at bracket depth 0.</summary>
private static List<string> SplitTop(string token, char sep)
{
var outp = new List<string>();
int depth = 0;
var cur = new System.Text.StringBuilder();
foreach (var ch in token)
{
if (ch is '[' or '(' or '{') depth++;
else if (ch is ']' or ')' or '}') depth = depth > 0 ? depth - 1 : 0;
if (ch == sep && depth == 0)
{
outp.Add(cur.ToString());
cur.Clear();
}
else
{
cur.Append(ch);
}
}
outp.Add(cur.ToString());
return outp;
}
}
@@ -0,0 +1,162 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
namespace OfficeCli.Core.Diagram;
// ---- semantic IR (sequence flavor) ------------------------------------------
public sealed class SeqParticipant { public string Id = ""; public string Label = ""; }
public sealed class SeqMessage
{
public string From = "", To = "", Label = "";
public bool Dashed; // dotted line (mermaid `--`) — conventionally a return
public bool Arrow; // has an arrowhead (`>>`, `>`, `x`, `)`)
}
public sealed class SequenceDiagram
{
public readonly List<SeqParticipant> Participants = new();
public readonly Dictionary<string, SeqParticipant> ById = new();
public readonly List<SeqMessage> Messages = new();
public SeqParticipant See(string id)
{
if (!ById.TryGetValue(id, out var p))
{
p = new SeqParticipant { Id = id, Label = id };
ById[id] = p; Participants.Add(p);
}
return p;
}
}
/// <summary>
/// Mermaid sequenceDiagram subset → lifeline layout → shared <see cref="LaidOutGraph"/>.
/// Participants become box nodes with a dashed lifeline; messages are horizontal
/// arrows stacked top→bottom (solid call / dashed return); self-messages loop.
/// Deferred: activation bars, alt/opt/loop fragments, notes.
/// </summary>
public static class SequenceLayout
{
// Participant/actor ids accept Unicode letters, not just ASCII — a
// fully-Chinese sequence (客户->>服务器: 登录) must parse, mirroring the
// flowchart parser. \p{L} = any letter, \p{N} = any digit.
private const string SeqId = @"[\p{L}\p{N}_]+";
private static readonly Regex Decl =
new(@"^(?:participant|actor)\s+(" + SeqId + @")(?:\s+as\s+(.+))?$", RegexOptions.IgnoreCase);
// `A->>B: msg`, plus optional activation control `+`/`-` on the target
// (`A->>+B`, `B-->>-A`) — we don't draw activation bars, but the message must
// still render rather than being dropped.
private static readonly Regex Msg =
new(@"^(" + SeqId + @")\s*(-{1,2}[>)x]{1,2})\s*[+-]?\s*(" + SeqId + @")\s*:\s*(.*)$");
public static SequenceDiagram Parse(string text)
{
var d = new SequenceDiagram();
// Split on ';' as well as newlines so the single-line form
// ("sequenceDiagram; A->>B: hi; B-->>A: ok") parses — same statement
// separator the flowchart parser already accepts.
foreach (var raw in text.Split('\n', ';'))
{
var line = raw.Trim();
if (line.Length == 0 || line.StartsWith("%%") ||
Regex.IsMatch(line, @"^sequenceDiagram\b", RegexOptions.IgnoreCase))
continue;
var md = Decl.Match(line);
if (md.Success)
{
var p = d.See(md.Groups[1].Value);
if (md.Groups[2].Success) p.Label = md.Groups[2].Value.Trim();
continue;
}
var mm = Msg.Match(line);
if (mm.Success)
{
var op = mm.Groups[2].Value;
d.See(mm.Groups[1].Value); d.See(mm.Groups[3].Value);
d.Messages.Add(new SeqMessage
{
From = mm.Groups[1].Value,
To = mm.Groups[3].Value,
Label = mm.Groups[4].Value.Trim(),
Dashed = op.StartsWith("--"),
Arrow = op.Contains('>') || op.Contains('x') || op.Contains(')'),
});
}
}
return d;
}
public static LaidOutGraph Layout(SequenceDiagram d)
{
const double boxH = 1.1, top = 0.8, hGap = 1.4, row = 1.15;
var order = d.Participants;
var lo = new LaidOutGraph { FontScale = 1.0 };
if (order.Count == 0)
throw new ArgumentException("sequence diagram has no participants.");
// participant x positions (left, width) + lifeline centre
var left = new Dictionary<string, double>();
var width = new Dictionary<string, double>();
var cxOf = new Dictionary<string, double>();
double cur = 0.8;
foreach (var p in order)
{
double w = Math.Max(2.4, TextWidth(p.Label) + 1.0);
left[p.Id] = cur; width[p.Id] = w; cxOf[p.Id] = cur + w / 2;
cur += w + hGap;
}
double bodyTop = top + boxH + 0.9;
double bottom = bodyTop + Math.Max(1, d.Messages.Count) * row + 0.6;
lo.SlideWidthCm = Math.Max(cur - hGap + 0.8, 12.0);
lo.SlideHeightCm = bottom + 0.8;
// participant boxes + lifelines
foreach (var p in order)
{
lo.Nodes.Add(new PlacedNode { Id = p.Id, Label = p.Label, Shape = FlowShape.Process,
X = left[p.Id], Y = top, W = width[p.Id], H = boxH });
lo.Edges.Add(new RoutedEdge
{
Dashed = true, ArrowAtEnd = false,
Points = new List<Pt> { new(cxOf[p.Id], top + boxH), new(cxOf[p.Id], bottom) },
});
}
// messages
for (int i = 0; i < d.Messages.Count; i++)
{
var m = d.Messages[i];
double y = bodyTop + i * row;
double x1 = cxOf[m.From], x2 = cxOf[m.To];
if (m.From == m.To)
{
double r = x1 + 1.4;
lo.Edges.Add(new RoutedEdge { ArrowAtEnd = true, Points = new List<Pt>
{ new(x1, y), new(r, y), new(r, y + 0.45), new(x1, y + 0.45) } });
if (m.Label.Length > 0)
lo.Labels.Add(new EdgeLabel { Text = m.Label, Cx = x1 + 1.0, Cy = y - 0.25, Opaque = false });
}
else
{
lo.Edges.Add(new RoutedEdge { ArrowAtEnd = m.Arrow, Dashed = m.Dashed,
Points = new List<Pt> { new(x1, y), new(x2, y) } });
if (m.Label.Length > 0)
lo.Labels.Add(new EdgeLabel { Text = m.Label, Cx = (x1 + x2) / 2, Cy = y - 0.5, Opaque = false });
}
}
return lo;
}
private static double TextWidth(string s)
{
double w = 0;
foreach (var c in s) w += c > 0x2E80 ? 0.58 : 0.30;
return w;
}
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.Json.Serialization;
namespace OfficeCli.Core;
public enum IssueType
{
Format,
Content,
Structure
}
public enum IssueSeverity
{
Error,
Warning,
Info
}
public class DocumentIssue
{
[JsonPropertyName("id")]
public string Id { get; set; } = "";
[JsonPropertyName("type")]
public IssueType Type { get; set; }
/// <summary>
/// Machine-readable issue subtype. Stable identifier agents can match on
/// (snake_case). Doubles as the value accepted by `view issues --type`
/// for narrow filtering. Examples: formula_not_evaluated,
/// field_not_evaluated, slide_field_not_evaluated,
/// chart_series_ref_missing_sheet, chart_cache_stale,
/// definedname_broken. Distinct from the broad <see cref="Type"/> enum
/// (Format / Content / Structure) which buckets the issue category.
/// </summary>
[JsonPropertyName("subtype")]
public string? Subtype { get; set; }
[JsonPropertyName("severity")]
public IssueSeverity Severity { get; set; }
[JsonPropertyName("path")]
public string Path { get; set; } = "";
[JsonPropertyName("message")]
public string Message { get; set; } = "";
[JsonPropertyName("context")]
public string? Context { get; set; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; set; }
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Runtime.CompilerServices;
namespace OfficeCli.Core;
/// <summary>
/// CONSISTENCY(dos-hardening): one source of truth for the resource limits that
/// protect officecli from hostile/malformed documents. These guard the three
/// denial-of-service classes a tiny crafted file can trigger:
///
/// - Decompression bombs: an OOXML zip whose entries inflate to gigabytes.
/// Enforced in <see cref="OfficeCli.Handlers.DocumentHandlerFactory"/> before
/// the Open XML SDK touches the package.
/// - Unbounded structural recursion: deeply nested tables / group shapes drive
/// the tree walkers and HTML/SVG renderers into an uncatchable
/// StackOverflowException (which escapes the top-level SafeRun handler and
/// hard-kills the process — fatal in the long-lived resident/watch servers).
/// Each recursive walker checks <see cref="MaxRecursionDepth"/> and throws a
/// friendly <see cref="CliException"/> instead.
/// - Catastrophic-regex backtracking: a user-supplied r"..." pattern against
/// document text. Bounded by <see cref="RegexMatchTimeout"/>, mirroring the
/// existing guard in <see cref="FindHelpers"/>.
///
/// Limits are deliberately generous — far beyond any legitimate document — so
/// real files are never affected; only adversarial inputs hit them.
/// </summary>
public static class DocumentLimits
{
/// <summary>
/// Maximum element nesting depth any recursive document walker / renderer
/// will descend before refusing. Real Office documents nest only a handful
/// of levels deep (Word caps nested tables at ~19; PPTX group nesting and
/// math run far lower), so 256 is comfortably above any legitimate file yet
/// low enough that even the heavy HTML/SVG renderer frames cannot overflow a
/// default ~1 MB thread-pool stack (the resident/watch server runs commands
/// on such threads). The <see cref="RuntimeHelpers"/> stack probe in
/// <see cref="EnsureDepth"/> backs this up for any unusually large frame.
/// </summary>
public const int MaxRecursionDepth = 256;
/// <summary>
/// Maximum total uncompressed size (bytes) of all entries in an OOXML
/// package. 2 GiB matches realistic large-but-legitimate documents (big
/// embedded media) while rejecting decompression bombs that inflate a few
/// KB of zip into many gigabytes.
/// </summary>
public const long MaxUncompressedBytes = 2L * 1024 * 1024 * 1024;
/// <summary>
/// Maximum number of entries in an OOXML package. Guards against zip files
/// crafted with millions of tiny entries (entry-count exhaustion).
/// </summary>
public const int MaxZipEntries = 100_000;
/// <summary>
/// Maximum overall compression ratio (uncompressed / compressed) tolerated
/// for a package. Genuine OOXML packages rarely exceed ~100×; a ratio far
/// above this is the signature of a decompression bomb.
/// </summary>
public const long MaxCompressionRatio = 1000;
/// <summary>
/// Hard timeout for matching a user-supplied regular expression against
/// document text. Mirrors <see cref="FindHelpers.RegexMatchTimeout"/> so
/// every find-style entry point fails fast on catastrophic backtracking
/// instead of hanging the process.
/// </summary>
public static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Throw a friendly <see cref="CliException"/> when a recursive walker /
/// renderer has descended too far. Call at the top of each recursive method
/// so a maliciously deep document fails with a clean error instead of an
/// uncatchable StackOverflowException.
///
/// Two complementary guards, because the safe depth depends on thread stack
/// size (the 8 MB main thread tolerates far deeper recursion than the ~1 MB
/// thread-pool threads the resident/watch server uses, and renderer frames
/// are large):
/// - <see cref="MaxRecursionDepth"/> bounds the worst-case time/O(n^2) cost
/// on any stack;
/// - <see cref="RuntimeHelpers.TryEnsureSufficientExecutionStack"/> probes
/// the *actual* remaining stack and trips before a real overflow, so the
/// guard adapts to whatever thread the call runs on (mirrors the probe in
/// <see cref="OfficeCli.Core.Formula.FormulaEvaluator"/>).
/// </summary>
public static void EnsureDepth(int depth)
{
if (depth > MaxRecursionDepth || !RuntimeHelpers.TryEnsureSufficientExecutionStack())
throw new CliException(
$"Document nesting exceeds the maximum supported depth (~{MaxRecursionDepth}); " +
"the file may be malformed or crafted to exhaust resources.")
{
Code = "max_depth_exceeded",
Suggestion = "Verify the document is a genuine Office file."
};
}
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.Json.Serialization;
namespace OfficeCli.Core;
/// <summary>
/// Represents a node in the document DOM tree.
/// This is the universal abstraction across Word/Excel/PowerPoint.
/// </summary>
public class DocumentNode
{
[JsonPropertyName("path")]
public string Path { get; set; } = "";
[JsonPropertyName("type")]
public string Type { get; set; } = "";
[JsonPropertyName("text")]
public string? Text { get; set; }
[JsonPropertyName("preview")]
public string? Preview { get; set; }
[JsonPropertyName("style")]
public string? Style { get; set; }
[JsonPropertyName("childCount")]
public int ChildCount { get; set; }
[JsonPropertyName("format")]
public Dictionary<string, object?> Format { get; set; } = new();
[JsonPropertyName("children")]
public List<DocumentNode> Children { get; set; } = new();
/// <summary>
/// Internal round-trip metadata that intentionally does not surface in
/// user-facing Format (CLI Get output, JSON envelopes). Used to carry
/// verbatim OOXML fragments (e.g. axisTitle.pPr, catTitle.pPr, series-
/// level spPr) between the chart Reader and the batch emitter without
/// polluting the public DocumentNode shape. Consumers that need these
/// values read from InternalFormat directly.
/// </summary>
[JsonIgnore]
public Dictionary<string, object?> InternalFormat { get; set; } = new();
}
+254
View File
@@ -0,0 +1,254 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using Drawing = DocumentFormat.OpenXml.Drawing;
namespace OfficeCli.Core;
/// <summary>
/// Cross-handler builders for DrawingML (`a:` namespace) color/fill elements.
/// Used by both PowerPointHandler (slide shapes, runs) and ExcelHandler (drawing-layer
/// shapes, chart series). Word's <c>w:</c> namespace has its own run-property color
/// model and does not share this helper.
/// </summary>
internal static class DrawingColorBuilder
{
/// <summary>
/// Parse a color string and return the appropriate DrawingML color element:
/// <c>a:srgbClr</c> (with optional <c>a:alpha</c>) for hex/named colors,
/// or <c>a:schemeClr</c> for theme color names (accent1..6, dk1/lt1/tx1/bg1/hlink/...).
/// </summary>
internal static OpenXmlElement BuildColorElement(string value)
{
// R8-4: split the trailing color transform chain
// ("accent1+lumMod50+lumOff20") from the base color before any
// recognition. Transforms are appended as a:lumMod / a:lumOff /
// a:shade / a:tint / a:satMod / a:satOff / a:hueMod / a:hueOff
// children. Pre-R8 these suffixes weren't a vocabulary, so feeding
// the round-tripped form back through Set silently failed scheme
// recognition.
string baseColor = value;
List<(string Name, int Val)>? transforms = null;
// Accept '+' (canonical Get round-trip form) or ':' (alternate form
// some authors reach for when the base is a scheme name). ':' is
// reserved for gradient prefixes ("radial:", "path:") and pattern
// foreground ("pct25:FF0000"), so we only honour it when the prefix
// is a recognised scheme color — otherwise it goes to the gradient
// / pattern parser as before.
var plus = value.IndexOf('+');
if (plus <= 0)
{
var colon = value.IndexOf(':');
if (colon > 0 && TryParseSchemeColor(value.Substring(0, colon)).HasValue)
plus = colon;
}
if (plus > 0)
{
baseColor = value.Substring(0, plus);
// Re-join remaining tokens whether separator was '+' or ':'.
transforms = ParseColorTransformSuffix(value.Substring(plus + 1));
}
OpenXmlElement colorEl;
var schemeColor = TryParseSchemeColor(baseColor);
var systemColor = TryParseSystemColor(baseColor);
if (schemeColor.HasValue)
{
colorEl = new Drawing.SchemeColor { Val = schemeColor.Value };
}
else if (systemColor != null)
{
// <a:sysClr val="window"/> etc. — the gradient/Get readback emits the
// bare OOXML sysClr name, which the hex parser rejected ("Invalid
// color value: 'window'"). Rebuild a real a:sysClr element with its
// conventional lastClr so the value renders even if the consuming
// app doesn't resolve the live system color.
colorEl = new Drawing.SystemColor
{
Val = systemColor.Value.val,
LastColor = systemColor.Value.lastClr,
};
}
else
{
var (rgb, alpha) = ParseHelpers.SanitizeColorForOoxml(baseColor);
var rgbEl = new Drawing.RgbColorModelHex { Val = rgb };
if (alpha.HasValue)
rgbEl.AppendChild(new Drawing.Alpha { Val = alpha.Value });
colorEl = rgbEl;
}
if (transforms != null)
AppendColorTransformChildren(colorEl, transforms);
return colorEl;
}
// R8-4: parse "lumMod50+lumOff20" → [("lumMod",50),("lumOff",20)]. Each
// token is name + integer percent (0..100). Unknown tokens are dropped
// silently to keep the input contract lenient — Get emits only the
// recognised set above, so a stray suffix is the caller's bug, not ours.
private static readonly HashSet<string> KnownTransforms = new(StringComparer.OrdinalIgnoreCase)
{
"lumMod", "lumOff", "shade", "tint", "satMod", "satOff", "hueMod", "hueOff", "alpha"
};
private static List<(string Name, int Val)> ParseColorTransformSuffix(string chain)
{
var result = new List<(string Name, int Val)>();
foreach (var token in chain.Split('+', StringSplitOptions.RemoveEmptyEntries))
{
// Two accepted forms:
// "lumMod75" — Get's canonical round-trip form, percent 0..100
// "lumMod=75000" — raw OOXML percentage 0..100000
// (matches the literal a:lumMod@val attribute,
// what users see in PowerPoint XML / docs)
// Both end up encoded as @val="75000" on the OOXML child. The name is
// a leading run of letters; the remainder is '='?<signed-int>. Scan the
// name by letters (not "first digit") so a leading '-' on the value
// stays with the value instead of being folded into the name.
int i = 0;
while (i < token.Length && char.IsLetter(token[i])) i++;
if (i == 0 || i == token.Length) continue;
var name = token.Substring(0, i);
if (!KnownTransforms.Contains(name))
throw new ArgumentException(
$"Unknown color transform '{name}'. Valid: lumMod, lumOff, shade, tint, satMod, satOff, hueMod, hueOff, alpha.");
bool eqForm = token[i] == '=';
string numText = eqForm ? token.Substring(i + 1) : token.Substring(i);
if (!int.TryParse(numText, out var raw))
throw new ArgumentException(
$"Invalid color transform '{token}': value must be an integer.");
// OOXML splits the transforms into two schema types:
// shade / tint / alpha → ST_PositiveFixedPercentage (0..100%),
// negatives forbidden.
// lumMod / lumOff / satMod / satOff / hueMod / hueOff
// → ST_Percentage: SIGNED and may exceed 100%
// (e.g. satMod200% to over-saturate, or
// satOff-10% to desaturate). Rejecting
// negatives wrongly aborted replay of the
// round-trip form Get emits for these
// (satOff val="-10000" → "satOff-10").
// Only the fixed-percentage family stays clamped 0..100.
bool fixedPct = name.ToLowerInvariant() is "shade" or "tint" or "alpha";
int maxPct = fixedPct ? 100 : 1000; // 1000% headroom for ST_Percentage
int minPct = fixedPct ? 0 : -1000; // signed for the Mod/Off family
int maxRaw = fixedPct ? 100000 : 1000000;
int minRaw = fixedPct ? 0 : -1000000;
int pct;
if (eqForm)
{
if (raw < minRaw || raw > maxRaw)
throw new ArgumentException(
$"Invalid color transform '{token}': raw value {raw} out of range {minRaw}-{maxRaw}.");
// OOXML raw units are 1/1000 of a percent. Integer division
// truncates values whose magnitude is 1..999 to 0 (lumMod=75 raw
// → 0 instead of 7.5%). Reject sub-1000 magnitudes so callers
// can't silently get a no-op; the percentage form covers that range.
if (raw != 0 && Math.Abs(raw) < 1000)
throw new ArgumentException(
$"Invalid color transform '{token}': raw value {raw} below 1000 truncates to 0%; use percentage form '{name}{raw / 1000}' or raw magnitude >= 1000.");
pct = raw / 1000;
}
else
{
if (raw < minPct || raw > maxPct)
throw new ArgumentException(
$"Invalid color transform '{token}': percentage {raw} out of range {minPct}-{maxPct}.");
pct = raw;
}
// Canonicalize: lumMod → lumMod (lowercase first letter? OOXML uses
// camelCase: lumMod, lumOff, satMod, satOff, hueMod, hueOff,
// shade, tint). KnownTransforms matches case-insensitively; we
// re-emit the canonical form here.
var canonical = name.ToLowerInvariant() switch
{
"lummod" => "lumMod",
"lumoff" => "lumOff",
"satmod" => "satMod",
"satoff" => "satOff",
"huemod" => "hueMod",
"hueoff" => "hueOff",
"shade" => "shade",
"tint" => "tint",
"alpha" => "alpha",
_ => name
};
result.Add((canonical, pct));
}
return result;
}
private static void AppendColorTransformChildren(OpenXmlElement colorEl, List<(string Name, int Val)> transforms)
{
const string aNs = "http://schemas.openxmlformats.org/drawingml/2006/main";
foreach (var (name, pct) in transforms)
{
var child = new OpenXmlUnknownElement("a", name, aNs);
// OOXML ST_PositivePercentage / ST_FixedPercentage uses 1000ths
// of a percent: 100 → 100000, 50 → 50000.
child.SetAttribute(new OpenXmlAttribute("", "val", null!, (pct * 1000).ToString()));
colorEl.AppendChild(child);
}
}
/// <summary>
/// Build an <c>a:solidFill</c> element with the appropriate color child (RGB or scheme).
/// </summary>
internal static Drawing.SolidFill BuildSolidFill(string colorValue)
{
var solidFill = new Drawing.SolidFill();
solidFill.Append(BuildColorElement(colorValue));
return solidFill;
}
/// <summary>
/// Try to parse an OOXML system color name (<c>a:sysClr</c> @val). Returns the
/// enum value plus a conventional lastClr hex fallback, or null when the input
/// is not a recognised system color. Only the slots that appear in real decks
/// (window / windowText, plus the common UI chrome colors) are mapped — the
/// full ST_SystemColorVal vocabulary is large but rarely authored.
/// </summary>
internal static (Drawing.SystemColorValues val, string lastClr)? TryParseSystemColor(string value)
{
return value.ToLowerInvariant().Trim() switch
{
"window" => (Drawing.SystemColorValues.Window, "FFFFFF"),
"windowtext" => (Drawing.SystemColorValues.WindowText, "000000"),
"background" => (Drawing.SystemColorValues.Background, "FFFFFF"),
"windowframe" => (Drawing.SystemColorValues.WindowFrame, "000000"),
"highlight" => (Drawing.SystemColorValues.Highlight, "0078D7"),
"highlighttext" => (Drawing.SystemColorValues.HighlightText, "FFFFFF"),
"btnface" => (Drawing.SystemColorValues.ButtonFace, "F0F0F0"),
"btntext" => (Drawing.SystemColorValues.ButtonText, "000000"),
"graytext" => (Drawing.SystemColorValues.GrayText, "6D6D6D"),
_ => null
};
}
/// <summary>
/// Try to parse a theme/scheme color name. Returns null if the input is a hex RGB value.
/// </summary>
internal static Drawing.SchemeColorValues? TryParseSchemeColor(string value)
{
return value.ToLowerInvariant().TrimStart('#') switch
{
"accent1" => Drawing.SchemeColorValues.Accent1,
"accent2" => Drawing.SchemeColorValues.Accent2,
"accent3" => Drawing.SchemeColorValues.Accent3,
"accent4" => Drawing.SchemeColorValues.Accent4,
"accent5" => Drawing.SchemeColorValues.Accent5,
"accent6" => Drawing.SchemeColorValues.Accent6,
"dk1" or "dark1" => Drawing.SchemeColorValues.Dark1,
"dk2" or "dark2" => Drawing.SchemeColorValues.Dark2,
"lt1" or "light1" => Drawing.SchemeColorValues.Light1,
"lt2" or "light2" => Drawing.SchemeColorValues.Light2,
"tx1" or "text1" => Drawing.SchemeColorValues.Text1,
"tx2" or "text2" => Drawing.SchemeColorValues.Text2,
"bg1" or "background1" => Drawing.SchemeColorValues.Background1,
"bg2" or "background2" => Drawing.SchemeColorValues.Background2,
"hlink" or "hyperlink" => Drawing.SchemeColorValues.Hyperlink,
"folhlink" or "followedhyperlink" => Drawing.SchemeColorValues.FollowedHyperlink,
_ => null
};
}
}
+389
View File
@@ -0,0 +1,389 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml;
using Drawing = DocumentFormat.OpenXml.Drawing;
namespace OfficeCli.Core;
/// <summary>
/// Shared helpers for building Drawing-namespace text/shape effects (a:effectLst children).
/// Used by both PPTX and Excel handlers to avoid code duplication.
/// Word uses a different namespace (w14) and has its own implementation.
/// </summary>
internal static class DrawingEffectsHelper
{
/// <summary>
/// Build an OuterShadow element from a value string.
/// Format: "COLOR[-BLUR[-ANGLE[-DIST[-OPACITY]]]]"
/// Defaults: blur=4pt, angle=45°, dist=3pt, opacity=40%
/// </summary>
public static Drawing.OuterShadow BuildOuterShadow(string value, Func<string, OpenXmlElement> colorBuilder)
{
var parts = SplitEffectParts(value);
var blurPt = ParseParam(parts, 1, 4.0, "shadow blur");
var angleDeg = ParseParam(parts, 2, 45.0, "shadow angle");
var distPt = ParseParam(parts, 3, 3.0, "shadow distance");
// Distinguish "user supplied opacity" from "default 40%": if the color
// carries an 8-digit hex alpha (#RRGGBBAA) and no explicit -OPACITY tail,
// the alpha-from-color must win over the 40% default so RRGGBBAA round-trips.
bool hasExplicitOpacity = parts.Length > 4;
var opacity = ParseParam(parts, 4, 40.0, "shadow opacity");
var shadow = new Drawing.OuterShadow
{
BlurRadius = (long)(blurPt * EmuConverter.EmuPerPoint),
Distance = (long)(distPt * EmuConverter.EmuPerPoint),
Direction = (int)(angleDeg * 60000),
Alignment = Drawing.RectangleAlignmentValues.TopLeft,
RotateWithShape = false
};
var clr = colorBuilder(parts[0]);
bool colorHasAlpha = clr.GetFirstChild<Drawing.Alpha>() != null;
// ColorEncodesAlpha: user wrote an 8-digit hex with alpha=FF — the
// alpha element is absent from the built color (SanitizeColorForOoxml
// drops the redundant 100% alpha child), but the caller's intent was
// an explicit "fully opaque" shadow. Suppress the 40% default so the
// explicit FF alpha doesn't silently downgrade to 40%.
bool colorEncodesAlpha = ColorEncodesExplicitAlpha(parts[0]);
if (hasExplicitOpacity || (!colorHasAlpha && !colorEncodesAlpha))
SetAlphaChildSkippingDefault(clr, (int)(opacity * 1000));
shadow.AppendChild(clr);
return shadow;
}
/// <summary>
/// Build an InnerShadow element from a value string. Mirrors BuildOuterShadow
/// — InnerShadow's CT_InnerShadow has BlurRadius / Distance / Direction
/// (no Alignment, no RotateWithShape), plus a color child supporting alpha.
/// Format: "COLOR[-BLUR[-ANGLE[-DIST[-OPACITY]]]]"
/// Defaults: blur=4pt, angle=45°, dist=3pt, opacity=40%
/// </summary>
public static Drawing.InnerShadow BuildInnerShadow(string value, Func<string, OpenXmlElement> colorBuilder)
{
var parts = SplitEffectParts(value);
var blurPt = ParseParam(parts, 1, 4.0, "innerShadow blur");
var angleDeg = ParseParam(parts, 2, 45.0, "innerShadow angle");
var distPt = ParseParam(parts, 3, 3.0, "innerShadow distance");
bool hasExplicitOpacity = parts.Length > 4;
var opacity = ParseParam(parts, 4, 40.0, "innerShadow opacity");
var shadow = new Drawing.InnerShadow
{
BlurRadius = (long)(blurPt * EmuConverter.EmuPerPoint),
Distance = (long)(distPt * EmuConverter.EmuPerPoint),
Direction = (int)(angleDeg * 60000),
};
var clr = colorBuilder(parts[0]);
bool colorHasAlpha = clr.GetFirstChild<Drawing.Alpha>() != null;
bool colorEncodesAlpha = ColorEncodesExplicitAlpha(parts[0]);
if (hasExplicitOpacity || (!colorHasAlpha && !colorEncodesAlpha))
SetAlphaChildSkippingDefault(clr, (int)(opacity * 1000));
shadow.AppendChild(clr);
return shadow;
}
/// <summary>
/// Build a Glow element from a value string.
/// Format: "COLOR[-RADIUS[-OPACITY]]"
/// Defaults: radius=8pt, opacity=75%
/// </summary>
public static Drawing.Glow BuildGlow(string value, Func<string, OpenXmlElement> colorBuilder)
{
var parts = SplitEffectParts(value);
var radiusPt = ParseParam(parts, 1, 8.0, "glow radius");
bool hasExplicitOpacity = parts.Length > 2;
var opacity = ParseParam(parts, 2, 75.0, "glow opacity");
var glow = new Drawing.Glow { Radius = (long)(radiusPt * EmuConverter.EmuPerPoint) };
var clr = colorBuilder(parts[0]);
bool colorHasAlpha = clr.GetFirstChild<Drawing.Alpha>() != null;
bool colorEncodesAlpha = ColorEncodesExplicitAlpha(parts[0]);
if (hasExplicitOpacity || (!colorHasAlpha && !colorEncodesAlpha))
SetAlphaChildSkippingDefault(clr, (int)(opacity * 1000));
glow.AppendChild(clr);
return glow;
}
/// <summary>
/// Returns true when <paramref name="colorInput"/> is an 8-digit hex form
/// (CSS #RRGGBBAA or OOXML AARRGGBB) — i.e. the caller explicitly encoded
/// an alpha byte. Even when that byte resolves to 0xFF (fully opaque),
/// SanitizeColorForOoxml drops the redundant alpha element, so a naive
/// "no alpha child → use default 40% / 75%" check would silently override
/// the user's "100% opaque" intent. Mirror SanitizeColorForOoxml's
/// detection here so shadow/glow honor the explicit encoding.
/// </summary>
private static bool ColorEncodesExplicitAlpha(string colorInput)
{
if (string.IsNullOrEmpty(colorInput)) return false;
var hex = colorInput.TrimStart('#');
return hex.Length == 8 && hex.All(c => char.IsAsciiHexDigit(c));
}
/// <summary>
/// Build a Reflection element from a value string.
/// Values: "tight"/"small", "half"/"true", "full", or numeric percentage.
/// </summary>
public static Drawing.Reflection BuildReflection(string value)
{
// Unknown preset names (and out-of-range numerics) used to silently
// fall back to "half" (90000), masking typos. Reject so the caller
// surfaces the value rather than writing a no-op effect.
int endPos;
switch (value.ToLowerInvariant())
{
case "tight": case "small": endPos = 55000; break;
case "true": case "half": endPos = 90000; break;
case "full": endPos = 100000; break;
default:
if (!int.TryParse(value, out var pct) || pct < 0 || pct > 100)
throw new ArgumentException(
$"Invalid 'reflection' value '{value}'. Valid presets: none, tight, small, half, true, full; or a numeric percentage 0-100.");
endPos = (int)Math.Min((long)pct * 1000, 100000);
break;
}
return new Drawing.Reflection
{
BlurRadius = 6350,
StartOpacity = 52000,
StartPosition = 0,
EndAlpha = 300,
EndPosition = endPos,
Distance = 0,
Direction = 5400000,
VerticalRatio = -100000,
Alignment = Drawing.RectangleAlignmentValues.BottomLeft,
RotateWithShape = false
};
}
/// <summary>
/// Build a SoftEdge element from a value string (radius in points).
/// </summary>
public static Drawing.SoftEdge BuildSoftEdge(string value)
{
var numStr = value.EndsWith("pt", StringComparison.OrdinalIgnoreCase) ? value[..^2].Trim() : value;
if (!double.TryParse(numStr, System.Globalization.CultureInfo.InvariantCulture, out var radiusPt)
|| double.IsNaN(radiusPt) || double.IsInfinity(radiusPt) || radiusPt < 0)
throw new ArgumentException($"Invalid 'softedge' value '{value}'. Expected a finite non-negative numeric radius in points.");
return new Drawing.SoftEdge { Radius = (long)(radiusPt * EmuConverter.EmuPerPoint) };
}
/// <summary>
/// Get or create EffectList in correct schema position within Drawing.RunProperties.
/// CT_TextCharacterProperties order: ln → fill → effectLst → highlight → ... → latin → ea → ...
/// </summary>
public static Drawing.EffectList EnsureRunEffectList(Drawing.RunProperties rPr)
{
var existing = rPr.GetFirstChild<Drawing.EffectList>();
if (existing != null) return existing;
var effectList = new Drawing.EffectList();
var insertBefore = (OpenXmlElement?)rPr.GetFirstChild<Drawing.Highlight>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.UnderlineFollowsText>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.Underline>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.UnderlineFillText>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.UnderlineFill>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.LatinFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.EastAsianFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.ComplexScriptFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.SymbolFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.HyperlinkOnClick>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.HyperlinkOnMouseOver>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.ExtensionList>();
if (insertBefore != null)
rPr.InsertBefore(effectList, insertBefore);
else
rPr.AppendChild(effectList);
return effectList;
}
/// <summary>
/// Insert a fill element at the correct schema position in Drawing.RunProperties.
/// CT_TextCharacterProperties order: ln → fill → effectLst → ... → latin → ea → ...
/// </summary>
public static void InsertFillInRunProperties(Drawing.RunProperties rPr, OpenXmlElement fillElement)
{
var insertBefore = (OpenXmlElement?)rPr.GetFirstChild<Drawing.EffectList>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.EffectDag>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.Highlight>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.LatinFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.EastAsianFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.ComplexScriptFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.SymbolFont>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.HyperlinkOnClick>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.HyperlinkOnMouseOver>()
?? (OpenXmlElement?)rPr.GetFirstChild<Drawing.ExtensionList>();
if (insertBefore != null)
rPr.InsertBefore(fillElement, insertBefore);
else
rPr.AppendChild(fillElement);
}
/// <summary>
/// Apply a text effect to a Drawing.Run's RunProperties effectLst.
/// Handles create/remove logic. Returns false if value is "none".
/// </summary>
public static void ApplyTextEffect<T>(Drawing.Run run, string value, Func<T> builder) where T : OpenXmlElement
{
var rPr = run.RunProperties ?? (run.RunProperties = new Drawing.RunProperties());
var effectList = EnsureRunEffectList(rPr);
effectList.RemoveAllChildren<T>();
if (value.Equals("none", StringComparison.OrdinalIgnoreCase) || value.Equals("false", StringComparison.OrdinalIgnoreCase))
{
if (!effectList.HasChildren) rPr.RemoveChild(effectList);
return;
}
// CT_EffectList children must appear in schema order (blur →
// fillOverlay → glow → innerShdw → outerShdw → prstShdw → reflection
// → softEdge); Excel/PowerPoint reject out-of-order trees with
// Sch_UnexpectedElementContentExpectingComplex. Insert before the
// first sibling that would otherwise come after us, instead of the
// naive AppendChild that lands every effect at the tail in arrival
// order.
InsertEffectInSchemaOrder(effectList, builder());
}
/// <summary>
/// Schema order for CT_EffectList children. Single source of truth for
/// every effectLst (run-level rPr and shape-level spPr, docx/xlsx/pptx) —
/// add a new effect type here only.
/// </summary>
private static readonly Type[] s_effectListChildOrder =
[
typeof(Drawing.Blur),
typeof(Drawing.FillOverlay),
typeof(Drawing.Glow),
typeof(Drawing.InnerShadow),
typeof(Drawing.OuterShadow),
typeof(Drawing.PresetShadow),
typeof(Drawing.Reflection),
typeof(Drawing.SoftEdge),
];
/// <summary>
/// Insert an effect element into a CT_EffectList at the correct schema
/// position (blur → fillOverlay → glow → innerShdw → outerShdw → prstShdw
/// → reflection → softEdge). Shared by run-level (rPr) and shape-level
/// (spPr) effectLst across all three handlers; out-of-order children are
/// silently dropped by Office, so callers must never AppendChild directly.
/// </summary>
internal static void InsertEffectInSchemaOrder(OpenXmlElement effectList, OpenXmlElement effect)
{
var targetIdx = Array.IndexOf(s_effectListChildOrder, effect.GetType());
foreach (var child in effectList.ChildElements)
{
var childIdx = Array.IndexOf(s_effectListChildOrder, child.GetType());
if (childIdx > targetIdx)
{
effectList.InsertBefore(effect, child);
return;
}
}
effectList.AppendChild(effect);
}
/// <summary>
/// Standard color builder for Drawing effects: sanitizes hex, creates RgbColorModelHex with optional alpha.
/// Use instead of duplicating the lambda pattern inline.
/// </summary>
public static OpenXmlElement BuildRgbColor(string colorValue)
{
var (rgb, alpha) = ParseHelpers.SanitizeColorForOoxml(colorValue);
var clr = new Drawing.RgbColorModelHex { Val = rgb };
if (alpha.HasValue) clr.AppendChild(new Drawing.Alpha { Val = alpha.Value });
return clr;
}
// --- Private helpers ---
/// <summary>
/// Set or replace the Alpha child on a color element. Callers like BuildOuterShadow
/// and BuildGlow apply an explicit opacity from the user value string; if the color
/// builder (e.g. ARGB hex like "80FF0000") already produced an Alpha child, blindly
/// appending another would yield two a:alpha siblings — invalid OOXML which Office
/// either rejects or interprets unpredictably. Replace any existing alpha to keep
/// the user's opacity authoritative for the effect.
/// </summary>
private static void SetAlphaChild(OpenXmlElement colorElement, int alphaVal)
{
var existing = colorElement.GetFirstChild<Drawing.Alpha>();
if (existing != null) existing.Remove();
colorElement.AppendChild(new Drawing.Alpha { Val = alphaVal });
}
/// <summary>
/// Same as SetAlphaChild, but skip emitting <a:alpha val="100000"/> — that
/// is OOXML's default ("fully opaque") and serializing it produces spurious
/// XML drift after a Set→reload roundtrip. Any existing alpha child is still
/// removed (an explicit 100% request must clear a prior non-default alpha).
/// </summary>
private static void SetAlphaChildSkippingDefault(OpenXmlElement colorElement, int alphaVal)
{
var existing = colorElement.GetFirstChild<Drawing.Alpha>();
if (existing != null) existing.Remove();
if (alphaVal == 100000) return;
colorElement.AppendChild(new Drawing.Alpha { Val = alphaVal });
}
private static double ParseParam(string[] parts, int index, double defaultValue, string paramName)
{
if (parts.Length <= index) return defaultValue;
var raw = parts[index];
// The historical contract is "bare double" — blur/dist in pt, angle
// in deg, opacity in %. Accept unit-qualified inputs that match each
// dimension so callers can write "5pt", "45deg", "40%" without
// forcing them to know the internal unit. Strip and parse the
// numeric prefix; reject unknown trailing letters.
var num = raw.Trim();
if (num.Length == 0)
throw new ArgumentException($"Invalid {paramName} value: '{raw}' (empty).");
// Strip a trailing alpha unit suffix (pt/deg/%/cm/in/px/emu). The
// numeric routes through pt/deg/% as-is — units other than pt for a
// pt-dimension still parse the number but the result is not
// converted; agents should stick to bare numbers or the native unit
// for now. The point of this fix is to stop ParseParam throwing on
// a unit-qualified token; a future pass can do real unit conversion.
int suffixStart = num.Length;
while (suffixStart > 0 && (char.IsLetter(num[suffixStart - 1]) || num[suffixStart - 1] == '%'))
suffixStart--;
var numPart = num[..suffixStart];
if (!double.TryParse(numPart, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var val)
|| double.IsNaN(val) || double.IsInfinity(val))
throw new ArgumentException($"Invalid {paramName} value: '{raw}'.");
return val;
}
/// <summary>
/// Split an effect value string into ["color", "p1", "p2", …] tokens.
/// Historical separator is '-', but '-' collides with negative numbers
/// (e.g. "red;-5" for a shadow with negative angle). Prefer ';' when
/// present; fall back to '-' for the legacy form. Empty tokens are
/// rejected up front so opacity/blur don't silently take the default
/// for a malformed input like "red;;5".
/// </summary>
private static string[] SplitEffectParts(string value)
{
if (string.IsNullOrEmpty(value))
throw new ArgumentException("Effect value cannot be empty.");
// Prefer ';' so negative numeric params (e.g. "-5") survive split.
// When ';' is present, treat '-' as part of a numeric value, not a
// separator. Fall back to '-' for the legacy form. In ';' mode,
// reject empty tokens up front — the historical '-' code defaulted
// them silently, which masked typos like "red;;5".
if (value.Contains(';'))
{
var parts = value.Split(';');
for (int i = 0; i < parts.Length; i++)
{
if (string.IsNullOrEmpty(parts[i]))
throw new ArgumentException($"Invalid effect value '{value}': empty token at position {i + 1}.");
}
return parts;
}
return value.Split('-');
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// The project's ONE edit-distance implementation, backing every
/// "did you mean" suggester (property names, schema help keys, selector
/// attribute keys, table column names). Optimal string alignment
/// (restricted Damerau-Levenshtein): insert/delete/substitute cost 1, and a
/// swap of two ADJACENT characters also costs 1 — so the most common
/// real-world typo class (`blod`→`bold`, `Salray`→`Salary`) scores 1 instead
/// of Levenshtein's 2 and stays inside tight suggestion thresholds.
/// Case-sensitive; callers lowercase both sides first (every suggester wants
/// case-insensitive ranking).
/// </summary>
internal static class EditDistance
{
public static int Damerau(string s, string t)
{
if (s.Length == 0) return t.Length;
if (t.Length == 0) return s.Length;
var d = new int[s.Length + 1, t.Length + 1];
for (int i = 0; i <= s.Length; i++) d[i, 0] = i;
for (int j = 0; j <= t.Length; j++) d[0, j] = j;
for (int i = 1; i <= s.Length; i++)
{
for (int j = 1; j <= t.Length; j++)
{
int cost = s[i - 1] == t[j - 1] ? 0 : 1;
d[i, j] = Math.Min(Math.Min(d[i - 1, j] + 1, d[i, j - 1] + 1), d[i - 1, j - 1] + cost);
if (i > 1 && j > 1 && s[i - 1] == t[j - 2] && s[i - 2] == t[j - 1])
d[i, j] = Math.Min(d[i, j], d[i - 2, j - 2] + 1);
}
}
return d[s.Length, t.Length];
}
}
+346
View File
@@ -0,0 +1,346 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
namespace OfficeCli.Core;
/// <summary>
/// Shared EMU (English Metric Unit) parsing and formatting.
/// 1 inch = 914400 EMU, 1 cm = 360000 EMU, 1 pt = 12700 EMU, 1 px = 9525 EMU.
/// Accepts: raw EMU integer, or suffixed with cm/in/pt/px/emu.
/// </summary>
internal static class EmuConverter
{
/// <summary>EMU per point as an integer. 1 pt = 12700 EMU (exact).
/// Use for integer-arithmetic conversions where the operand is already int/long
/// (preserves the surrounding cast/truncation semantics).</summary>
public const int EmuPerPoint = 12700;
/// <summary>EMU per point as a double. 1 pt = 12700 EMU (exact).
/// Use for floating-point conversions (<c>pt * EmuPerPointF</c>, <c>emu / EmuPerPointF</c>).</summary>
public const double EmuPerPointF = 12700.0;
/// <summary>Convert points to EMU, rounding to the nearest EMU. 1 pt = 12700 EMU.</summary>
public static long PointsToEmu(double points) => (long)Math.Round(points * EmuPerPointF);
/// <summary>Convert EMU to points (no rounding). 1 pt = 12700 EMU.</summary>
public static double EmuToPoints(long emu) => emu / EmuPerPointF;
/// <summary>EMU per pixel at 96 DPI. 1 px = 9525 EMU (exact). int / double pair,
/// same usage rule as <see cref="EmuPerPoint"/>. (Renderers that scale for retina
/// use their own local ratio — do not assume px is always 9525.)</summary>
public const int EmuPerPx = 9525;
public const double EmuPerPxF = 9525.0;
/// <summary>EMU per centimetre. 1 cm = 360000 EMU (exact). int / double pair.</summary>
public const int EmuPerCm = 360000;
public const double EmuPerCmF = 360000.0;
/// <summary>EMU per inch. 1 in = 914400 EMU (exact). int / double pair.</summary>
public const int EmuPerInch = 914400;
public const double EmuPerInchF = 914400.0;
/// <summary>
/// Parse a dimension/position string into EMU (long).
/// Supported formats: "914400" (raw EMU), "914400emu", "2.54cm", "1in", "72pt", "96px".
/// Negative values are allowed (for positions like x, y).
/// Throws ArgumentException on invalid input.
/// </summary>
public static long ParseEmu(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Invalid length value: input is null or empty.");
value = value.Trim();
long result;
// mm = cm/10. CSS-natural sibling of cm; AI assistants reach for mm
// before pt/in. Test in longer-suffix order so "mm" is matched before
// any bare-number fallback. ("Q" = mm/4 in CSS, also accepted.)
if (value.EndsWith("mm", StringComparison.OrdinalIgnoreCase))
{
result = ParseWithUnit(value, 2, 36000.0, "mm");
}
else if (value.EndsWith("cm", StringComparison.OrdinalIgnoreCase))
{
result = ParseWithUnit(value, 2, EmuPerCmF, "cm");
}
else if (value.EndsWith("in", StringComparison.OrdinalIgnoreCase))
{
result = ParseWithUnit(value, 2, EmuPerInchF, "in");
}
else if (value.EndsWith("pc", StringComparison.OrdinalIgnoreCase))
{
// pica = 12pt (CSS / typographic standard).
result = ParseWithUnit(value, 2, 12.0 * EmuPerPointF, "pc");
}
else if (value.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
{
result = ParseWithUnit(value, 2, EmuPerPointF, "pt");
}
else if (value.EndsWith("px", StringComparison.OrdinalIgnoreCase))
{
result = ParseWithUnit(value, 2, EmuPerPxF, "px");
}
else if (value.EndsWith("Q", StringComparison.OrdinalIgnoreCase))
{
// CSS quarter-millimeter (Q = mm/4). Rare but harmless to support.
result = ParseWithUnit(value, 1, 9000.0, "Q");
}
else if (value.EndsWith("emu", StringComparison.OrdinalIgnoreCase))
{
// Explicit emu suffix — symmetric with FormatEmu's tiny-value fallback.
var numberPart = value[..^3];
if (string.IsNullOrWhiteSpace(numberPart))
throw new ArgumentException($"Invalid length value '{value}': missing numeric value before 'emu' unit.");
if (!long.TryParse(numberPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out result))
throw new ArgumentException($"Invalid length value '{value}': '{numberPart}' before 'emu' unit is not an integer.");
}
else if (HasKnownUnitSuffix(value, out var unit))
{
// 'em' / 'ex' / 'rem' depend on font context that ParseEmu does not have.
throw new ArgumentException(
$"Invalid length value '{value}': unit '{unit}' is not supported (requires font context). " +
$"Supported units: cm, mm, in, pt, pc, px, Q, emu (or raw EMU integer).");
}
else
{
// Raw EMU value: integer form preferred. Bare scientific notation
// ("1e6", "1.5e2") is also accepted for parity with ParseFontSize
// — but a plain decimal like "0.0001" is NOT (fractional EMU is
// meaningless; users wanting sub-EMU sizes have unit suffixes).
// Reject NaN/Infinity early so downstream emit never produces a
// "NaNpt" / "Infinitypt" string.
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out result))
{
// already parsed
}
else if ((value.Contains('e') || value.Contains('E'))
&& double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d))
{
if (double.IsNaN(d) || double.IsInfinity(d))
throw new ArgumentException(
$"Invalid length value '{value}': must be a finite number.");
if (d > long.MaxValue || d < long.MinValue)
throw new ArgumentException(
$"Invalid length value '{value}': exceeds the maximum EMU range.");
result = (long)Math.Round(d);
}
else
{
throw new ArgumentException(
$"Invalid length value '{value}': expected a number with optional unit suffix (cm, mm, in, pt, pc, px, Q, emu).");
}
}
return result;
}
/// <summary>
/// Parse EMU and safely cast to int, throwing on overflow.
/// </summary>
public static int ParseEmuAsInt(string value)
{
long emu = ParseEmu(value);
if (emu < 0)
throw new ArgumentException($"Negative dimension value '{value}' is not allowed. This property requires a non-negative value.");
if (emu > int.MaxValue)
throw new OverflowException($"EMU value {emu} (from '{value}') exceeds the maximum allowed value of {int.MaxValue}.");
return (int)emu;
}
/// <summary>
/// Parse line width value into EMU (int). Bare numbers are treated as points (pt).
/// Suffixed values (cm/in/pt/px) are parsed normally via ParseEmu.
/// </summary>
public static int ParseLineWidth(string value)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Invalid line width value: input is null or empty.");
var trimmed = value.Trim();
// If bare integer/decimal with no unit suffix, treat as points.
// Reject NaN/Infinity here so we never construct a "NaNpt" / "Infinitypt"
// string that would slip past ParseEmu's suffix branch with a misleading
// error tail.
if (double.TryParse(trimmed, NumberStyles.Float, CultureInfo.InvariantCulture, out var bare)
&& !HasKnownUnitSuffix(trimmed, out _))
{
if (double.IsNaN(bare) || double.IsInfinity(bare))
throw new ArgumentException($"Invalid line width value '{value}': must be a finite number.");
trimmed += "pt";
}
return ParseEmuAsInt(trimmed);
}
/// <summary>
/// Format an EMU value as a human-readable string (e.g., "2.54cm").
/// </summary>
public static string FormatEmu(long emu)
{
if (emu == 0) return "0cm";
// Emit in the first unit whose 2-decimal form round-trips back to the
// exact source EMU. cm is preferred (most readable for document
// geometry); pt and inch follow because many PowerPoint-authored values
// are exact in pt/inch but NOT in cm — e.g. a 13.333in (960pt) slide
// width = 12192000 EMU is 33.8667cm, which "0.##" cm rounds to 33.87cm
// and re-parses as 12193200 EMU (off by 1200). Only when no cm/pt/inch
// form survives the round trip (a value not cleanly expressible in any
// human unit) do we fall back to raw `<n>emu`. This keeps Get readback
// BOTH exact (no dump→replay position drift) AND unit-qualified
// (cm/in/pt) — instead of dumping ugly raw EMU whenever cm alone misses.
return TryFormatUnit(emu, EmuPerCmF, "cm")
?? TryFormatUnit(emu, EmuPerPointF, "pt")
?? TryFormatUnit(emu, EmuPerInchF, "in")
?? emu.ToString(CultureInfo.InvariantCulture) + "emu";
}
/// <summary>
/// Like <see cref="FormatEmu"/>, but tries pt FIRST (then cm, in). Used for
/// slide-size paired readback (slideWidth / slideHeight) where the default
/// widescreen size (12192000 x 6858000 EMU) is exact in pt for the width
/// (960pt) but exact in cm for the height (19.05cm) — so the default
/// cm-first ordering mixes units across a semantically paired property.
/// Preferring pt keeps both members on the same unit for any pt-exact
/// size, with cm/in still reachable for sizes that are clean cm but not
/// clean pt (e.g. A4).
/// </summary>
public static string FormatEmuPtFirst(long emu)
{
if (emu == 0) return "0pt";
return TryFormatUnit(emu, EmuPerPointF, "pt")
?? TryFormatUnit(emu, EmuPerCmF, "cm")
?? TryFormatUnit(emu, EmuPerInchF, "in")
?? emu.ToString(CultureInfo.InvariantCulture) + "emu";
}
/// <summary>
/// Format two paired EMU dimensions (e.g. slideWidth + slideHeight) using
/// the SAME unit when one exists that exact-round-trips both. Iterates
/// pt → cm → in; falls back to per-value <see cref="FormatEmu"/> if no
/// shared unit fits. This prevents semantically paired readback (slide
/// size, page size, etc.) from mixing units when one value is pt-exact
/// and the other is cm-exact.
/// </summary>
public static (string, string) FormatEmuPaired(long emuA, long emuB)
{
foreach (var (perUnit, suffix) in new[] {
(EmuPerPointF, "pt"), (EmuPerCmF, "cm"), (EmuPerInchF, "in") })
{
var a = TryFormatUnit(emuA, perUnit, suffix);
var b = TryFormatUnit(emuB, perUnit, suffix);
if (a != null && b != null) return (a, b);
}
return (FormatEmu(emuA), FormatEmu(emuB));
}
/// <summary>
/// Format <paramref name="emu"/> in human-readable cm/pt/in even when the
/// 2-decimal form does NOT round-trip exactly. Used for derived geometry
/// like table colWidths (where auto-distribution divides the table width
/// across N columns and produces values that don't survive a 2-decimal
/// cm round-trip — e.g. 22cm / 3 = 7.33cm = 2640000 EMU, off by 1200 EMU
/// = 0.13mm imperceptible). Falling back to raw `<n>emu` integers here
/// gives unreadable output for the common UI case; the small round-trip
/// loss is acceptable for these derived widths.
/// </summary>
public static string FormatEmuLossy(long emu)
{
if (emu == 0) return "0cm";
// Prefer exact (same as FormatEmu) when available; otherwise fall
// back to nearest cm at 2-decimal precision.
return TryFormatUnit(emu, EmuPerCmF, "cm")
?? TryFormatUnit(emu, EmuPerPointF, "pt")
?? TryFormatUnit(emu, EmuPerInchF, "in")
?? (emu / EmuPerCmF).ToString("0.##", CultureInfo.InvariantCulture) + "cm";
}
/// <summary>
/// Format <paramref name="emu"/> in <paramref name="suffix"/> units with a
/// 2-decimal "0.##" form, returning the string only when it re-parses back to
/// the exact source EMU (non-lossy round trip). Returns null when the value
/// rounds to 0 in this unit or the round trip loses precision, so the caller
/// can try the next unit.
/// </summary>
private static string? TryFormatUnit(long emu, double emuPerUnit, string suffix)
{
var str = (emu / emuPerUnit).ToString("0.##", CultureInfo.InvariantCulture);
if (str == "0" || str == "-0") return null;
var reparsed = (long)Math.Round(double.Parse(str, CultureInfo.InvariantCulture) * emuPerUnit);
return reparsed == emu ? str + suffix : null;
}
/// <summary>
/// Format an EMU value as points (e.g., "2pt"). Used for line widths and other
/// thin values where points are more natural than centimeters.
/// </summary>
public static string FormatLineWidth(long emu)
{
// CONSISTENCY(emu-roundtrip): tiny sub-0.01-pt widths (anything below
// ~127 EMU) collapse to "0pt" under the "0.##" pt format, then
// re-parse as 0 EMU on replay — silently turning legal hairline
// <a:ln w="1..126"> strokes into <a:ln w="0"/>. Fall back to a raw
// "<n>emu" form (round-trips through ParseEmu/ParseLineWidth) when
// the pt form would round to "0". Round-trip lossiness above the
// floor (e.g. 180000 EMU -> "14.17pt" -> 179959 EMU) is the
// pre-existing 0.01-pt readback contract callers expect — only
// zero-collapse is plugged here.
if (emu == 0) return "0pt";
var pt = emu / EmuPerPointF;
var ptStr = pt.ToString("0.##", CultureInfo.InvariantCulture);
if (ptStr == "0" || ptStr == "-0")
return emu.ToString(CultureInfo.InvariantCulture) + "emu";
return $"{ptStr}pt";
}
/// <summary>
/// Try to parse a dimension string into EMU. Returns false if parsing fails.
/// </summary>
public static bool TryParseEmu(string value, out long emu)
{
try
{
emu = ParseEmu(value);
return true;
}
catch
{
emu = 0;
return false;
}
}
private static long ParseWithUnit(string value, int suffixLen, double factor, string unit)
{
var numberPart = value[..^suffixLen];
if (string.IsNullOrWhiteSpace(numberPart))
throw new ArgumentException($"Missing numeric value before '{unit}' unit in '{value}'.");
if (!double.TryParse(numberPart, NumberStyles.Float, CultureInfo.InvariantCulture, out var number) || double.IsNaN(number) || double.IsInfinity(number))
throw new ArgumentException($"Invalid numeric value '{numberPart}' before '{unit}' unit in '{value}'.");
return (long)Math.Round(number * factor);
}
private static bool HasKnownUnitSuffix(string value, out string unit)
{
// Font-relative or viewport-relative units that ParseEmu cannot resolve
// (no font / viewport context at this layer). Listed so the bare-number
// fallback rejects them with a "not supported" message instead of
// silently re-interpreting "5em" as raw EMU.
// Order matters: longer suffixes first so "rem" doesn't shadow "em".
string[] unsupported = { "rem", "em", "ex", "vw", "vh" };
foreach (var u in unsupported)
{
if (value.EndsWith(u, StringComparison.OrdinalIgnoreCase))
{
unit = u;
return true;
}
}
unit = "";
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml.Packaging;
using AP = DocumentFormat.OpenXml.ExtendedProperties;
namespace OfficeCli.Core;
/// <summary>
/// Shared Extended Properties (app.xml) Get/Set logic for all document types.
/// </summary>
internal static class ExtendedPropertiesHandler
{
/// <summary>
/// Populate Format dictionary with extended properties.
/// </summary>
public static void PopulateExtendedProperties(ExtendedFilePropertiesPart? propsPart, DocumentNode node)
{
var props = propsPart?.Properties;
if (props == null) return;
if (props.Template?.Text != null)
node.Format["extended.template"] = props.Template.Text;
if (props.Manager?.Text != null)
node.Format["extended.manager"] = props.Manager.Text;
if (props.Company?.Text != null)
node.Format["extended.company"] = props.Company.Text;
if (props.Application?.Text != null)
node.Format["extended.application"] = props.Application.Text;
if (props.ApplicationVersion?.Text != null)
node.Format["extended.applicationVersion"] = props.ApplicationVersion.Text;
if (props.Pages?.Text != null)
node.Format["extended.pages"] = int.TryParse(props.Pages.Text, out var p) ? (object)p : props.Pages.Text;
if (props.Words?.Text != null)
node.Format["extended.words"] = int.TryParse(props.Words.Text, out var w) ? (object)w : props.Words.Text;
if (props.Characters?.Text != null)
node.Format["extended.characters"] = int.TryParse(props.Characters.Text, out var c) ? (object)c : props.Characters.Text;
if (props.Lines?.Text != null)
node.Format["extended.lines"] = int.TryParse(props.Lines.Text, out var l) ? (object)l : props.Lines.Text;
if (props.Paragraphs?.Text != null)
node.Format["extended.paragraphs"] = int.TryParse(props.Paragraphs.Text, out var para) ? (object)para : props.Paragraphs.Text;
if (props.TotalTime?.Text != null)
node.Format["extended.totalTime"] = int.TryParse(props.TotalTime.Text, out var t) ? (object)t : props.TotalTime.Text;
}
/// <summary>
/// Try to Set an extended.* property. Returns true if handled.
/// </summary>
public static bool TrySetExtendedProperty(ExtendedFilePropertiesPart? propsPart, string key, string value)
{
if (propsPart == null) return false;
propsPart.Properties ??= new AP.Properties();
var props = propsPart.Properties;
switch (key)
{
case "extended.template":
(props.Template ??= new AP.Template()).Text = value;
break;
case "extended.manager":
(props.Manager ??= new AP.Manager()).Text = value;
break;
case "extended.company":
(props.Company ??= new AP.Company()).Text = value;
break;
default:
return false;
}
props.Save();
return true;
}
/// <summary>
/// Get the ExtendedFilePropertiesPart, creating if necessary for Set operations.
/// </summary>
public static ExtendedFilePropertiesPart? GetOrCreateExtendedPart(object doc)
{
return doc switch
{
WordprocessingDocument w => w.ExtendedFilePropertiesPart ?? w.AddExtendedFilePropertiesPart(),
SpreadsheetDocument s => s.ExtendedFilePropertiesPart ?? s.AddExtendedFilePropertiesPart(),
PresentationDocument p => p.ExtendedFilePropertiesPart ?? p.AddExtendedFilePropertiesPart(),
_ => null
};
}
public static ExtendedFilePropertiesPart? GetExtendedPart(object doc)
{
return doc switch
{
WordprocessingDocument w => w.ExtendedFilePropertiesPart,
SpreadsheetDocument s => s.ExtendedFilePropertiesPart,
PresentationDocument p => p.ExtendedFilePropertiesPart,
_ => null
};
}
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Resolves file sources from local paths, HTTP(S) URLs, or data URIs into a seekable stream.
/// Unified counterpart to <see cref="ImageSource"/> for non-image binary files (media, 3D models, CSV, etc.).
///
/// Supports:
/// - Local file path: "/tmp/model.glb", "C:\media\video.mp4"
/// - HTTP(S) URL: "https://example.com/video.mp4"
/// - Data URI: "data:video/mp4;base64,AAAA..."
///
/// Returns a MemoryStream (always seekable) and the detected file extension.
/// </summary>
internal static class FileSource
{
/// <summary>
/// Resolve a source string into a seekable MemoryStream and file extension (with dot, e.g. ".glb").
/// Caller is responsible for disposing the returned stream.
/// </summary>
public static (MemoryStream Stream, string Extension) Resolve(string source)
{
if (string.IsNullOrWhiteSpace(source))
throw new ArgumentException("File source cannot be empty");
if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
return ResolveDataUri(source);
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return ResolveUrl(source);
return ResolveFile(source);
}
/// <summary>
/// Check whether a string looks like a resolvable source (URL, data URI, or existing local file).
/// Useful for distinguishing file/URL sources from inline data (e.g. CSV inline vs file path).
/// </summary>
public static bool IsResolvable(string source)
{
if (string.IsNullOrWhiteSpace(source)) return false;
if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return true;
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)) return true;
if (source.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) return true;
return File.Exists(source);
}
/// <summary>
/// Resolve a source to text lines (for CSV/text data).
/// </summary>
public static string[] ResolveLines(string source)
{
var (stream, _) = Resolve(source);
using (stream)
{
using var reader = new StreamReader(stream);
var text = reader.ReadToEnd();
return text.Split('\n')
.Select(l => l.TrimEnd('\r'))
.ToArray();
}
}
private static (MemoryStream, string) ResolveFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"File not found: {path}");
return (new MemoryStream(File.ReadAllBytes(path)), Path.GetExtension(path).ToLowerInvariant());
}
private static (MemoryStream, string) ResolveUrl(string url)
{
// SSRF guard: same connect-time public-IP enforcement as image fetch —
// refuse loopback / private / link-local / cloud-metadata targets. See
// SsrfGuard. Without this, a caller-supplied data=/model3d=/media= URL
// is an SSRF primitive when officecli runs on untrusted input.
var handler = SsrfGuard.CreateGuardedHandler("file");
using var client = new HttpClient(handler, disposeHandler: true) { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Add("User-Agent", "OfficeCLI");
var response = client.GetAsync(url).GetAwaiter().GetResult();
response.EnsureSuccessStatusCode();
// Bound memory use: fail fast on an honest oversized Content-Length, then
// read through the shared SsrfGuard.ReadBounded so a chunked / lying
// response can't exhaust memory either. Same cap as the image path.
var declared = response.Content.Headers.ContentLength;
if (declared is > SsrfGuard.MaxRemoteBytes)
throw new ArgumentException(
$"Remote file exceeds {SsrfGuard.MaxRemoteBytes / (1024 * 1024)} MB limit.");
var bytes = SsrfGuard.ReadBounded(
response.Content.ReadAsStream(), SsrfGuard.MaxRemoteBytes, url, "file");
// Try extension from URL path
var uri = new Uri(url);
var ext = Path.GetExtension(uri.AbsolutePath).ToLowerInvariant();
// Fallback: infer from content-type header
if (string.IsNullOrEmpty(ext))
{
var mime = response.Content.Headers.ContentType?.MediaType;
ext = MimeToExtension(mime);
}
return (new MemoryStream(bytes), ext);
}
private static (MemoryStream, string) ResolveDataUri(string dataUri)
{
var commaIdx = dataUri.IndexOf(',');
if (commaIdx < 0)
throw new ArgumentException("Invalid data URI: missing comma separator");
var header = dataUri[..commaIdx];
var data = dataUri[(commaIdx + 1)..];
if (!header.Contains("base64", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Only base64-encoded data URIs are supported");
var mimeStart = header.IndexOf(':') + 1;
var mimeEnd = header.IndexOf(';');
var mime = mimeEnd > mimeStart ? header[mimeStart..mimeEnd] : header[mimeStart..];
var ext = MimeToExtension(mime);
return (new MemoryStream(Convert.FromBase64String(data)), ext);
}
private static string MimeToExtension(string? mime)
{
if (string.IsNullOrEmpty(mime)) return "";
return mime.ToLowerInvariant() switch
{
// Video
"video/mp4" => ".mp4",
"video/quicktime" => ".mov",
"video/x-msvideo" or "video/avi" => ".avi",
"video/x-ms-wmv" => ".wmv",
"video/mpeg" => ".mpg",
"video/webm" => ".webm",
// Audio
"audio/mpeg" or "audio/mp3" => ".mp3",
"audio/wav" or "audio/x-wav" => ".wav",
"audio/mp4" or "audio/x-m4a" => ".m4a",
"audio/x-ms-wma" => ".wma",
"audio/ogg" => ".ogg",
// 3D
"model/gltf-binary" => ".glb",
// Text/data
"text/csv" => ".csv",
"text/plain" => ".txt",
_ => ""
};
}
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Shared find-pattern parsing and matching for text find/replace across handlers.
/// Word and PowerPoint accept the same find syntax (plain text or r"..." regex) and
/// bound regex matching with the same catastrophic-backtracking timeout, so the
/// parse + match-range logic lives here rather than being duplicated per handler.
/// </summary>
internal static class FindHelpers
{
// BUG-TESTER fuzz-2: bound regex match time on user-supplied find patterns to
// prevent catastrophic-backtracking DoS (e.g. "(a+)+b" against long inputs).
internal static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Parse a find pattern: plain text or regex (r"..." / r'...' prefix).
/// Returns (pattern, isRegex).
/// </summary>
internal static (string Pattern, bool IsRegex) ParseFindPattern(string value)
{
// r"..." or r'...' → regex
if (value.Length >= 3 && value[0] == 'r' && (value[1] == '"' || value[1] == '\''))
{
var quote = value[1];
var endIdx = value.LastIndexOf(quote);
if (endIdx > 1)
return (value[2..endIdx], true);
}
return (value, false);
}
/// <summary>
/// Find all match ranges in fullText using either plain text or regex.
/// Returns list of (start, length) pairs, sorted by start ascending.
/// Zero-length regex matches are skipped. Invalid patterns and
/// catastrophic-backtracking timeouts surface as ArgumentException.
/// </summary>
internal static List<(int Start, int Length)> FindMatchRanges(string fullText, string pattern, bool isRegex)
{
var ranges = new List<(int Start, int Length)>();
if (isRegex)
{
try
{
// Bound matching with a hard timeout so catastrophic-backtracking
// patterns (e.g. "(a+)+b") fail fast instead of hanging the process.
foreach (Match m in Regex.Matches(fullText, pattern, RegexOptions.None, RegexMatchTimeout))
{
if (m.Length > 0) // skip zero-length matches
ranges.Add((m.Index, m.Length));
}
}
catch (RegexParseException ex)
{
throw new ArgumentException($"Invalid regex pattern '{pattern}': {ex.Message}", ex);
}
catch (RegexMatchTimeoutException ex)
{
throw new ArgumentException(
$"Regex pattern '{pattern}' exceeded {RegexMatchTimeout.TotalSeconds}s match timeout (catastrophic backtracking?)",
ex);
}
}
else
{
int idx = 0;
while ((idx = fullText.IndexOf(pattern, idx, StringComparison.Ordinal)) >= 0)
{
ranges.Add((idx, pattern.Length));
idx += pattern.Length;
}
}
return ranges;
}
}
+757
View File
@@ -0,0 +1,757 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Collections.Concurrent;
namespace OfficeCli.Core;
/// <summary>
/// Lightweight TTF/TTC font reader. Extracts the per-font line-height ratio
/// used to size CSS line boxes for paragraph rendering.
///
/// Latin: ratio = (hhea.ascender + |hhea.descender| + hhea.lineGap) / unitsPerEm
/// CJK: ratio = (asc + dsc + 2 × v7) / UPM
/// v7 = (15 × (asc + dsc) + 50) / 100 [design units]
/// </summary>
internal static class FontMetricsReader
{
/// <summary>
/// Line-height ratio for a font file. Returns 1.0 on any read failure.
/// </summary>
public static double GetLineHeightRatio(string fontFilePath, int fontIndex = 0)
{
try
{
using var fs = File.OpenRead(fontFilePath);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, fontIndex);
if (offset < 0) return 1.0;
var tables = FindTables(reader, offset);
if (tables.head < 0 || tables.hhea < 0) return 1.0;
fs.Position = tables.head + 18;
var upm = ReadUInt16BE(reader);
if (upm == 0) return 1.0;
if (tables.os2 >= 0 && TryReadOs2(reader, tables.os2, out var os2) && os2.IsCjk)
{
int asc = os2.UseTypo ? os2.TypoAscent : os2.WinAscent;
int dsc = os2.UseTypo ? -os2.TypoDescent : os2.WinDescent;
int v7 = (15 * (asc + dsc) + 50) / 100;
return (double)(asc + dsc + 2 * v7) / upm;
}
fs.Position = tables.hhea + 4;
var ascender = ReadInt16BE(reader);
var descender = ReadInt16BE(reader);
var lineGap = ReadInt16BE(reader);
int total = ascender + Math.Abs((int)descender) + Math.Max(0, (int)lineGap);
return (double)total / upm;
}
catch
{
return 1.0;
}
}
private struct Os2Metrics
{
public int WinAscent;
public int WinDescent;
public int TypoAscent;
public int TypoDescent;
public int TypoLineGap;
public bool UseTypo;
public bool IsCjk;
}
/// <summary>
/// CJK detection via OS/2 ulCodePageRange1 bits 17-21:
/// 17 = JIS Japan, 18 = GB2312 PRC, 19 = Korean Wansung,
/// 20 = Big5 Taiwan, 21 = Korean Johab.
/// </summary>
private static bool TryReadOs2(BinaryReader r, long os2Offset, out Os2Metrics m)
{
m = default;
try
{
r.BaseStream.Position = os2Offset;
ushort version = ReadUInt16BE(r);
r.BaseStream.Position = os2Offset + 62;
ushort fsSelection = ReadUInt16BE(r);
m.UseTypo = (fsSelection & 0x80) != 0;
r.BaseStream.Position = os2Offset + 68;
m.TypoAscent = ReadInt16BE(r);
m.TypoDescent = ReadInt16BE(r);
m.TypoLineGap = ReadInt16BE(r);
m.WinAscent = ReadUInt16BE(r);
m.WinDescent = ReadUInt16BE(r);
if (version >= 1)
{
r.BaseStream.Position = os2Offset + 78;
uint cp1 = ReadUInt32BE(r);
const uint cjkMask = (1U << 17) | (1U << 18) | (1U << 19) | (1U << 20) | (1U << 21);
m.IsCjk = (cp1 & cjkMask) != 0;
}
return true;
}
catch
{
return false;
}
}
private static long GetFontOffset(BinaryReader reader, int fontIndex)
{
reader.BaseStream.Position = 0;
var tag = ReadUInt32BE(reader);
// TTC collection header
if (tag == 0x74746366)
{
reader.BaseStream.Position = 8;
var numFonts = (int)ReadUInt32BE(reader);
if (fontIndex >= numFonts) return -1;
reader.BaseStream.Position = 12 + fontIndex * 4;
return ReadUInt32BE(reader);
}
return 0;
}
private struct TableOffsets
{
public long head;
public long os2;
public long hhea;
public long name;
public long cmap;
}
private static TableOffsets FindTables(BinaryReader reader, long fontOffset)
{
reader.BaseStream.Position = fontOffset + 4;
var numTables = ReadUInt16BE(reader);
reader.BaseStream.Position = fontOffset + 12;
var t = new TableOffsets { head = -1, os2 = -1, hhea = -1, name = -1, cmap = -1 };
for (int i = 0; i < numTables; i++)
{
var tag = ReadUInt32BE(reader);
reader.BaseStream.Position += 4;
var off = (long)ReadUInt32BE(reader);
reader.BaseStream.Position += 4;
if (tag == 0x68656164) t.head = off;
else if (tag == 0x4F532F32) t.os2 = off;
else if (tag == 0x68686561) t.hhea = off;
else if (tag == 0x6E616D65) t.name = off;
else if (tag == 0x636D6170) t.cmap = off;
if (t.head >= 0 && t.os2 >= 0 && t.hhea >= 0 && t.name >= 0 && t.cmap >= 0) break;
}
return t;
}
private static ushort ReadUInt16BE(BinaryReader r)
{
var b = r.ReadBytes(2);
return (ushort)((b[0] << 8) | b[1]);
}
private static short ReadInt16BE(BinaryReader r)
{
var b = r.ReadBytes(2);
return (short)((b[0] << 8) | b[1]);
}
private static uint ReadUInt32BE(BinaryReader r)
{
var b = r.ReadBytes(4);
return (uint)((b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]);
}
// ==================== Font lookup ====================
private static List<string> GetFontDirs()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dirs = new List<string>();
if (OperatingSystem.IsMacOS())
{
dirs.Add(Path.Combine(home, "Library/Fonts"));
dirs.Add("/Library/Fonts");
dirs.Add("/System/Library/Fonts");
dirs.Add("/System/Library/Fonts/Supplemental");
var officeFonts = "/Applications/Microsoft Word.app/Contents/Resources/DFonts";
if (Directory.Exists(officeFonts)) dirs.Add(officeFonts);
}
else if (OperatingSystem.IsWindows())
{
dirs.Add(Environment.GetFolderPath(Environment.SpecialFolder.Fonts));
dirs.Add(Path.Combine(home, @"AppData\Local\Microsoft\Windows\Fonts"));
}
else
{
dirs.Add(Path.Combine(home, ".fonts"));
dirs.Add("/usr/share/fonts");
dirs.Add("/usr/local/share/fonts");
}
return dirs;
}
/// <summary>Family-name → (file path, font collection index). Built lazily.</summary>
private static Dictionary<string, (string path, int idx)>? s_familyIndex;
private static readonly object s_familyIndexLock = new();
private static Dictionary<string, (string path, int idx)> BuildFamilyIndex()
{
var map = new Dictionary<string, (string, int)>(StringComparer.OrdinalIgnoreCase);
foreach (var dir in GetFontDirs())
{
if (!Directory.Exists(dir)) continue;
IEnumerable<string> files;
try { files = Directory.EnumerateFiles(dir, "*.*", SearchOption.AllDirectories); }
catch { continue; }
foreach (var file in files)
{
var ext = Path.GetExtension(file);
if (ext is not (".ttf" or ".otf" or ".ttc")) continue;
try
{
foreach (var (faceIdx, family) in EnumerateFaceFamilies(file))
{
map.TryAdd(family, (file, faceIdx));
map.TryAdd(family.Replace(" ", ""), (file, faceIdx));
}
}
catch
{
// ignore unreadable file; fall through to stem-based fallback
}
// stem fallback for fast lookup of common cases
var stem = Path.GetFileNameWithoutExtension(file);
if (!string.IsNullOrEmpty(stem))
map.TryAdd(stem, (file, 0));
}
}
return map;
}
private static Dictionary<string, (string path, int idx)> GetFamilyIndex()
{
if (s_familyIndex != null) return s_familyIndex;
lock (s_familyIndexLock)
{
s_familyIndex ??= BuildFamilyIndex();
return s_familyIndex;
}
}
private static IEnumerable<(int faceIndex, string family)> EnumerateFaceFamilies(string path)
{
using var fs = File.OpenRead(path);
using var reader = new BinaryReader(fs);
fs.Position = 0;
var tag = ReadUInt32BE(reader);
int faceCount;
long[] faceOffsets;
if (tag == 0x74746366)
{
fs.Position = 8;
faceCount = (int)ReadUInt32BE(reader);
faceOffsets = new long[faceCount];
fs.Position = 12;
for (int i = 0; i < faceCount; i++)
faceOffsets[i] = ReadUInt32BE(reader);
}
else
{
faceCount = 1;
faceOffsets = new[] { 0L };
}
for (int faceIdx = 0; faceIdx < faceCount; faceIdx++)
{
var tables = FindTables(reader, faceOffsets[faceIdx]);
if (tables.name < 0) continue;
foreach (var family in ReadFamilyNames(reader, tables.name))
yield return (faceIdx, family);
}
}
private static IEnumerable<string> ReadFamilyNames(BinaryReader reader, long nameTableOffset)
{
var fs = reader.BaseStream;
fs.Position = nameTableOffset;
var format = ReadUInt16BE(reader);
var count = ReadUInt16BE(reader);
var stringOffset = ReadUInt16BE(reader);
// Collect candidate (platform/lang priority, raw bytes, encoding) tuples; emit sorted.
var records = new List<(int priority, byte[] bytes, int encoding)>();
long recordsStart = fs.Position;
for (int i = 0; i < count; i++)
{
fs.Position = recordsStart + i * 12;
var platformId = ReadUInt16BE(reader);
var encodingId = ReadUInt16BE(reader);
var languageId = ReadUInt16BE(reader);
var nameId = ReadUInt16BE(reader);
var length = ReadUInt16BE(reader);
var strOff = ReadUInt16BE(reader);
// Family-name name IDs: 1 (family), 16 (preferred family), 4 (full name)
if (nameId != 1 && nameId != 4 && nameId != 16) continue;
// Skip languages other than English/Unicode-default
bool isEnglish =
(platformId == 3 && (languageId == 0x0409 || languageId == 0)) ||
(platformId == 0) ||
(platformId == 1 && languageId == 0);
if (!isEnglish) continue;
int priority =
(nameId == 16 ? 0 : nameId == 1 ? 10 : 20) +
(platformId == 3 && encodingId == 1 ? 0 :
platformId == 3 && encodingId == 10 ? 1 :
platformId == 0 ? 2 :
platformId == 1 ? 5 : 9);
var savedPos = fs.Position;
fs.Position = nameTableOffset + stringOffset + strOff;
var bytes = reader.ReadBytes(length);
fs.Position = savedPos;
int enc =
(platformId == 3 && (encodingId == 0 || encodingId == 1 || encodingId == 10)) ? 1 :
(platformId == 0) ? 1 :
0;
records.Add((priority, bytes, enc));
}
records.Sort((a, b) => a.priority.CompareTo(b.priority));
foreach (var (_, bytes, enc) in records)
{
string s = enc == 1
? System.Text.Encoding.BigEndianUnicode.GetString(bytes)
: System.Text.Encoding.Latin1.GetString(bytes);
s = s.Trim();
if (s.Length > 0) yield return s;
}
}
/// <summary>
/// Look up a font by family name. Returns the file path or null if not present.
/// </summary>
public static string? FindFontFile(string fontFamily)
{
var hit = FindFont(fontFamily);
return hit?.path;
}
/// <summary>
/// Look up a font by family name, returning both the file path and the
/// face index inside a TTC collection.
/// </summary>
public static (string path, int idx)? FindFont(string fontFamily)
{
if (string.IsNullOrEmpty(fontFamily)) return null;
var idx = GetFamilyIndex();
if (idx.TryGetValue(fontFamily, out var hit)) return hit;
if (idx.TryGetValue(fontFamily.Replace(" ", ""), out hit)) return hit;
return null;
}
// ==================== Cached ratio lookup ====================
// ConcurrentDictionary (not a plain Dictionary): GetRatio runs on parallel
// render threads (e.g. concurrent ViewAsHtml calls), and an unsynchronized
// Dictionary write corrupts its internal state. GetOrAdd runs the factory
// WITHOUT holding a lock, so the font-file I/O below never blocks another
// thread (no deadlock); a same-key race just recomputes the identical ratio
// and the last writer wins, which is harmless for this pure computation.
private static readonly ConcurrentDictionary<string, double> s_ratioCache = new(StringComparer.OrdinalIgnoreCase);
public static double GetRatio(string fontFamily)
{
return s_ratioCache.GetOrAdd(fontFamily, static family =>
{
var hit = FindFont(family);
return hit.HasValue ? GetLineHeightRatio(hit.Value.path, hit.Value.idx) : 1.0;
});
}
// ==================== Ascent/Descent override ====================
/// <summary>
/// Return ascent/descent split (as percentage of em). CJK fonts get
/// a +round(0.15 × (asc+dsc)) padding on each side. Latin fonts take
/// the larger of the two ascent/descent pairs available in the font;
/// the line-gap field, when present, folds into the ascent side.
/// Returns (0, 0) when the font isn't locatable.
/// </summary>
public static (double ascentPctEm, double descentPctEm) GetSplitAscDscOverride(string fontFamily)
{
var hit = FindFont(fontFamily);
if (!hit.HasValue) return (0, 0);
try
{
using var fs = File.OpenRead(hit.Value.path);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, hit.Value.idx);
if (offset < 0) return (0, 0);
var tables = FindTables(reader, offset);
if (tables.head < 0 || tables.hhea < 0) return (0, 0);
fs.Position = tables.head + 18;
var upm = ReadUInt16BE(reader);
if (upm == 0) return (0, 0);
Os2Metrics os2 = default;
bool haveOs2 = tables.os2 >= 0 && TryReadOs2(reader, tables.os2, out os2);
if (haveOs2 && os2.IsCjk)
{
int asc = os2.UseTypo ? os2.TypoAscent : os2.WinAscent;
int dsc = os2.UseTypo ? -os2.TypoDescent : os2.WinDescent;
int v7 = (15 * (asc + dsc) + 50) / 100;
return ((asc + v7) * 100.0 / upm, (dsc + v7) * 100.0 / upm);
}
fs.Position = tables.hhea + 4;
var fallbackAsc = ReadInt16BE(reader);
var fallbackDsc = ReadInt16BE(reader);
var lineGap = ReadInt16BE(reader);
int fallbackTotal = fallbackAsc + Math.Abs((int)fallbackDsc) + Math.Max(0, (int)lineGap);
// Latin split: descent = primary descent; total = larger of
// the two pairs; ascent = total descent. A line-gap, when
// present, folds into the ascent side.
int primaryAsc = haveOs2 && os2.WinAscent > 0 ? os2.WinAscent : fallbackAsc;
int primaryDsc = haveOs2 && os2.WinDescent > 0 ? os2.WinDescent : Math.Abs((int)fallbackDsc);
int total = Math.Max(primaryAsc + primaryDsc, fallbackTotal);
int ascUnits = total - primaryDsc;
return (ascUnits * 100.0 / upm, primaryDsc * 100.0 / upm);
}
catch
{
return (0, 0);
}
}
/// <summary>
/// Returns true when every codepoint in <paramref name="text"/> maps to
/// a non-zero glyph in the font's cmap. Used to detect bullet-marker
/// font fallback at render time — when the rPr-pinned font lacks a
/// glyph, the renderer switches to a wider fallback face that inflates
/// the line. Returns false on any read failure so the caller can
/// default to the conservative "fallback may happen" branch.
/// </summary>
public static bool HasGlyphsForChars(string fontFamily, string text)
{
if (string.IsNullOrEmpty(text)) return true;
var hit = FindFont(fontFamily);
if (!hit.HasValue) return false;
try
{
using var fs = File.OpenRead(hit.Value.path);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, hit.Value.idx);
if (offset < 0) return false;
var tables = FindTables(reader, offset);
if (tables.cmap < 0) return false;
var sub = SelectCmapSubtable(reader, tables.cmap);
if (sub.format != 4 && sub.format != 12) return false;
foreach (var cp in EnumerateCodepoints(text))
{
if (!CmapHasCodepoint(reader, sub, cp)) return false;
}
return true;
}
catch
{
return false;
}
}
private struct CmapSubtable
{
public long offset;
public int format;
}
private static CmapSubtable SelectCmapSubtable(BinaryReader r, long cmapOffset)
{
var result = new CmapSubtable { offset = -1, format = 0 };
r.BaseStream.Position = cmapOffset + 2;
var numTables = ReadUInt16BE(r);
long bestF4 = -1, bestF12 = -1, fallbackF4 = -1;
for (int i = 0; i < numTables; i++)
{
r.BaseStream.Position = cmapOffset + 4 + i * 8;
ushort platformId = ReadUInt16BE(r);
ushort encodingId = ReadUInt16BE(r);
uint subOff = ReadUInt32BE(r);
// platform 3 + encoding 1 (UCS-2) → format 4
// platform 3 + encoding 10 (UCS-4) or platform 0 (Unicode) → format 12
r.BaseStream.Position = cmapOffset + subOff;
ushort format = ReadUInt16BE(r);
if (format == 12 && (platformId == 3 || platformId == 0))
bestF12 = cmapOffset + subOff;
else if (format == 4 && platformId == 3 && encodingId == 1)
bestF4 = cmapOffset + subOff;
else if (format == 4 && bestF4 < 0 && platformId == 0)
bestF4 = cmapOffset + subOff;
// Symbol-encoded fonts (Symbol/Wingdings family) ship only a
// platform=3, encoding=0 cmap whose segments live in the PUA range
// U+F000-U+F0FF. OpenType cmap spec admits this as a legitimate
// subtable; without it, lvlText codepoints declared in numbering
// rPr can't be resolved against the font's own coverage.
else if (format == 4 && platformId == 3 && encodingId == 0)
fallbackF4 = cmapOffset + subOff;
}
if (bestF12 >= 0) { result.offset = bestF12; result.format = 12; }
else if (bestF4 >= 0) { result.offset = bestF4; result.format = 4; }
else if (fallbackF4 >= 0) { result.offset = fallbackF4; result.format = 4; }
return result;
}
private static IEnumerable<int> EnumerateCodepoints(string s)
{
for (int i = 0; i < s.Length; i++)
{
int cp = char.IsHighSurrogate(s[i]) && i + 1 < s.Length && char.IsLowSurrogate(s[i + 1])
? char.ConvertToUtf32(s[i], s[i + 1])
: s[i];
if (cp > 0xFFFF) i++;
yield return cp;
}
}
private static bool CmapHasCodepoint(BinaryReader r, CmapSubtable sub, int cp)
{
if (sub.format == 12)
{
r.BaseStream.Position = sub.offset + 12;
uint numGroups = ReadUInt32BE(r);
int lo = 0, hi = (int)numGroups - 1;
while (lo <= hi)
{
int mid = (lo + hi) >> 1;
r.BaseStream.Position = sub.offset + 16 + mid * 12;
uint startChar = ReadUInt32BE(r);
uint endChar = ReadUInt32BE(r);
uint startGid = ReadUInt32BE(r);
if ((uint)cp < startChar) hi = mid - 1;
else if ((uint)cp > endChar) lo = mid + 1;
else return startGid + ((uint)cp - startChar) != 0;
}
return false;
}
if (sub.format == 4)
{
if (cp > 0xFFFF) return false;
r.BaseStream.Position = sub.offset + 6;
int segCount = ReadUInt16BE(r) / 2;
long endOff = sub.offset + 14;
long startOff = endOff + segCount * 2 + 2;
long deltaOff = startOff + segCount * 2;
long rangeOff = deltaOff + segCount * 2;
for (int i = 0; i < segCount; i++)
{
r.BaseStream.Position = endOff + i * 2;
ushort end = ReadUInt16BE(r);
if (end < cp) continue;
r.BaseStream.Position = startOff + i * 2;
ushort start = ReadUInt16BE(r);
if (start > cp) return false;
r.BaseStream.Position = deltaOff + i * 2;
short idDelta = ReadInt16BE(r);
r.BaseStream.Position = rangeOff + i * 2;
ushort idRangeOff = ReadUInt16BE(r);
int gid;
if (idRangeOff == 0)
{
gid = (cp + idDelta) & 0xFFFF;
}
else
{
long glyphIdAddr = rangeOff + i * 2 + idRangeOff + (cp - start) * 2;
r.BaseStream.Position = glyphIdAddr;
int raw = ReadUInt16BE(r);
gid = raw == 0 ? 0 : (raw + idDelta) & 0xFFFF;
}
return gid != 0;
}
return false;
}
return false;
}
/// <summary>
/// Return the font's cap-height as a fraction of em. Returns 0 when the
/// font's OS/2 table version is below 2 (the field isn't published) or
/// the font isn't locatable.
/// </summary>
public static double GetCapHeightRatio(string fontFamily)
{
var hit = FindFont(fontFamily);
if (!hit.HasValue) return 0;
try
{
using var fs = File.OpenRead(hit.Value.path);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, hit.Value.idx);
if (offset < 0) return 0;
var tables = FindTables(reader, offset);
if (tables.head < 0 || tables.os2 < 0) return 0;
fs.Position = tables.head + 18;
var upm = ReadUInt16BE(reader);
if (upm == 0) return 0;
fs.Position = tables.os2;
var version = ReadUInt16BE(reader);
if (version < 2) return 0;
fs.Position = tables.os2 + 88;
var capHeight = ReadInt16BE(reader);
if (capHeight <= 0) return 0;
return (double)capHeight / upm;
}
catch
{
return 0;
}
}
/// <summary>
/// Per-font default sub/super metrics from OS/2 (all values are
/// fractions of em). OOXML §17.3.2.42 vertAlign:superscript/subscript
/// reduces the run's font and shifts the baseline; both the reduced
/// size and the offset come from the font's OS/2 ySub/SuperscriptYSize
/// and ySub/SuperscriptYOffset fields. Returns all-zero when the font
/// can't be located.
/// </summary>
public readonly record struct SuperSubMetrics(
double SuperSizeEm,
double SuperOffsetEm,
double SubSizeEm,
double SubOffsetEm)
{
public bool IsEmpty => SuperSizeEm == 0 && SubSizeEm == 0;
}
// ConcurrentDictionary for the same reason as s_ratioCache: parallel render
// threads share this cache, and GetOrAdd's factory runs lock-free so the
// font-file read in ReadSuperSubMetrics cannot deadlock or serialize.
private static readonly ConcurrentDictionary<string, SuperSubMetrics> s_superSubCache
= new(StringComparer.OrdinalIgnoreCase);
public static SuperSubMetrics GetSuperSubMetrics(string fontFamily)
{
if (string.IsNullOrEmpty(fontFamily)) return default;
return s_superSubCache.GetOrAdd(fontFamily, static family => ReadSuperSubMetrics(family));
}
private static SuperSubMetrics ReadSuperSubMetrics(string fontFamily)
{
var hit = FindFont(fontFamily);
if (!hit.HasValue) return default;
try
{
using var fs = File.OpenRead(hit.Value.path);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, hit.Value.idx);
if (offset < 0) return default;
var tables = FindTables(reader, offset);
if (tables.head < 0 || tables.os2 < 0) return default;
fs.Position = tables.head + 18;
var upm = ReadUInt16BE(reader);
if (upm == 0) return default;
// OS/2 layout (v0+): all 8 sub/super fields live in the first
// record block, no version gate needed.
// +10 ySubscriptXSize +12 ySubscriptYSize
// +14 ySubscriptXOffset +16 ySubscriptYOffset
// +18 ySuperscriptXSize +20 ySuperscriptYSize
// +22 ySuperscriptXOffset +24 ySuperscriptYOffset
fs.Position = tables.os2 + 12;
var subYSize = ReadInt16BE(reader);
fs.Position = tables.os2 + 16;
var subYOffset = ReadInt16BE(reader);
fs.Position = tables.os2 + 20;
var supYSize = ReadInt16BE(reader);
fs.Position = tables.os2 + 24;
var supYOffset = ReadInt16BE(reader);
return new SuperSubMetrics(
SuperSizeEm: supYSize > 0 ? (double)supYSize / upm : 0,
SuperOffsetEm: supYOffset > 0 ? (double)supYOffset / upm : 0,
SubSizeEm: subYSize > 0 ? (double)subYSize / upm : 0,
SubOffsetEm: subYOffset > 0 ? (double)subYOffset / upm : 0);
}
catch
{
return default;
}
}
/// <summary>
/// Return per-font ascent/descent percentages relative to em, suitable for
/// CSS @font-face overrides. (0,0) when the font cannot be located.
/// </summary>
public static (double ascentPct, double descentPct) GetAscentDescentOverride(string fontFamily)
{
var hit = FindFont(fontFamily);
if (!hit.HasValue) return (0, 0);
try
{
using var fs = File.OpenRead(hit.Value.path);
using var reader = new BinaryReader(fs);
var offset = GetFontOffset(reader, hit.Value.idx);
if (offset < 0) return (0, 0);
var tables = FindTables(reader, offset);
if (tables.head < 0 || tables.hhea < 0) return (0, 0);
fs.Position = tables.head + 18;
var upm = ReadUInt16BE(reader);
if (upm == 0) return (0, 0);
if (tables.os2 >= 0 && TryReadOs2(reader, tables.os2, out var os2) && os2.IsCjk)
{
int asc = os2.UseTypo ? os2.TypoAscent : os2.WinAscent;
int dsc = os2.UseTypo ? -os2.TypoDescent : os2.WinDescent;
int v7 = (15 * (asc + dsc) + 50) / 100;
return ((asc + v7) * 100.0 / upm, (dsc + v7) * 100.0 / upm);
}
fs.Position = tables.hhea + 4;
var ascender = ReadInt16BE(reader);
var descender = ReadInt16BE(reader);
return (ascender * 100.0 / upm, Math.Abs((int)descender) * 100.0 / upm);
}
catch
{
return (0, 0);
}
}
}
@@ -0,0 +1,162 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Numerics;
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Complex number foundation ====================
//
// Excel stores complex numbers as TEXT ("3+4i" / "2-3j"). This is the shared
// base for the whole IM* family: parse an Excel complex string into a
// System.Numerics.Complex, run the math there, and format the result back to
// Excel's exact textual form (coefficient-1 omitted, suffix preserved). The
// engineering functions are then thin wrappers over Complex's operators.
// Parse "a+bi" / "a-bj" / "bi" / "a" / "i" / "-i" into a Complex. Returns
// false on a malformed string (caller surfaces #NUM!). suffix echoes the
// imaginary unit found ('i' default, 'j' if the input used j).
private static bool TryParseComplex(string s, out Complex value, out char suffix)
{
value = Complex.Zero;
suffix = 'i';
s = s.Trim();
if (s.Length == 0) { value = Complex.Zero; return true; }
char last = s[^1];
if (last is 'i' or 'j' or 'I' or 'J')
{
suffix = char.ToLowerInvariant(last);
string body = s[..^1]; // strip the imaginary unit
// Split real and imaginary at the +/- that separates them — not the
// leading sign and not the sign of an exponent (e+3).
int split = -1;
for (int k = 1; k < body.Length; k++)
if (body[k] is '+' or '-' && body[k - 1] is not ('e' or 'E'))
split = k;
string realStr = split < 0 ? "0" : body[..split];
string imagStr = split < 0 ? body : body[split..];
// empty / lone-sign imaginary coefficient means ±1 ("i", "-i", "3+i")
double imag = imagStr is "" or "+" ? 1 : imagStr is "-" ? -1
: (TryNum(imagStr, out var iv) ? iv : double.NaN);
double real = realStr is "" or "+" ? 0 : realStr is "-" ? 0
: (TryNum(realStr, out var rv) ? rv : double.NaN);
if (double.IsNaN(real) || double.IsNaN(imag)) return false;
value = new Complex(real, imag);
return true;
}
// purely real
if (!TryNum(s, out var realOnly)) return false;
value = new Complex(realOnly, 0);
return true;
static bool TryNum(string x, out double d) =>
double.TryParse(x, NumberStyles.Any, CultureInfo.InvariantCulture, out d);
}
// Format a Complex the way Excel does: "3+4i", "3-4i", "5", "i", "-i", "2i".
private static string FormatComplex(Complex c, char suffix)
{
double re = c.Real, im = c.Imaginary;
// Clean up -0 so it never prints a stray minus.
if (re == 0) re = 0;
if (im == 0) im = 0;
if (im == 0) return FmtNum(re);
string imagPart = im switch
{
1 => $"{suffix}",
-1 => $"-{suffix}",
_ => $"{FmtNum(im)}{suffix}",
};
if (re == 0) return imagPart;
// both parts present — join with the imaginary part's sign
if (im > 0)
return im == 1 ? $"{FmtNum(re)}+{suffix}" : $"{FmtNum(re)}+{FmtNum(im)}{suffix}";
return im == -1 ? $"{FmtNum(re)}-{suffix}" : $"{FmtNum(re)}-{FmtNum(Math.Abs(im))}{suffix}";
}
// Excel general-number text: up to 15 significant digits, trailing zeros
// trimmed. Matches the cell renderer's 15-sig rounding so a complex string's
// components line up with what Excel persists.
private static string FmtNum(double v)
{
if (v == 0) return "0";
if (double.IsNaN(v) || double.IsInfinity(v)) return "#NUM!";
// G15 = exactly Excel's 15-significant-digit width (matches the stored
// complex string component); "R" would emit up to 17 round-trip digits
// and diverge in the last place.
return v.ToString("G15", CultureInfo.InvariantCulture);
}
// Pull a complex argument out of the arg list (string or bare number).
private static bool ArgComplex(object? a, out Complex c, out char suffix)
{
c = Complex.Zero; suffix = 'i';
if (a is FormulaResult { IsNumeric: true } n) { c = new Complex(n.NumericValue!.Value, 0); return true; }
if (a is FormulaResult r) return TryParseComplex(r.AsString(), out c, out suffix);
return false;
}
// COMPLEX(real, imaginary, [suffix]).
private FormulaResult? EvalComplex(List<object> args)
{
if (args.Count < 2) return null;
double re = args[0] is FormulaResult r ? r.AsNumber() : 0;
double im = args[1] is FormulaResult i ? i.AsNumber() : 0;
char suf = 'i';
if (args.Count > 2 && args[2] is FormulaResult sfx)
{
var ss = sfx.AsString();
if (ss is not ("i" or "j")) return FormulaResult.Error("#VALUE!");
suf = ss[0];
}
return FR_S(FormatComplex(new Complex(re, im), suf));
}
// Unary IM* returning a complex string (IMSQRT, IMEXP, IMSIN, ...).
private FormulaResult? ImUnary(List<object> args, Func<Complex, Complex> f)
{
if (args.Count < 1 || !ArgComplex(args[0], out var c, out var suf)) return FormulaResult.Error("#NUM!");
var result = f(c);
if (double.IsNaN(result.Real) || double.IsNaN(result.Imaginary)
|| double.IsInfinity(result.Real) || double.IsInfinity(result.Imaginary))
return FormulaResult.Error("#NUM!"); // e.g. IMLN(0), IMDIV by 0
return FR_S(FormatComplex(result, suf));
}
// Unary IM* returning a real number (IMABS, IMREAL, IMAGINARY, IMARGUMENT).
private FormulaResult? ImScalar(List<object> args, Func<Complex, double> f)
{
if (args.Count < 1 || !ArgComplex(args[0], out var c, out _)) return FormulaResult.Error("#NUM!");
return FR(f(c));
}
// Variadic IM* folding a list of complex args (IMSUM, IMPRODUCT).
private FormulaResult? ImFold(List<object> args, Complex seed, Func<Complex, Complex, Complex> op)
{
Complex acc = seed; char suf = 'i'; bool any = false;
foreach (var a in AllArgs(args))
{
if (!ArgComplex(a, out var c, out var s)) return FormulaResult.Error("#NUM!");
if (any && s != suf) return FormulaResult.Error("#NUM!"); // mixed i/j
suf = s; acc = any ? op(acc, c) : c; any = true;
}
return any ? FR_S(FormatComplex(acc, suf)) : FormulaResult.Error("#NUM!");
}
// Binary IM* (IMSUB, IMDIV).
private FormulaResult? ImBinary(List<object> args, Func<Complex, Complex, Complex> op)
{
if (args.Count < 2 || !ArgComplex(args[0], out var a, out var sa) || !ArgComplex(args[1], out var b, out var sb))
return FormulaResult.Error("#NUM!");
if (sa != sb && a.Imaginary != 0 && b.Imaginary != 0) return FormulaResult.Error("#NUM!");
return FR_S(FormatComplex(op(a, b), sa));
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Shorthand constructors ====================
private static FormulaResult FR(double v) => FormulaResult.Number(v);
private static FormulaResult FR_S(string v) => FormulaResult.Str(v);
private static FormulaResult FR_B(bool v) => FormulaResult.Bool(v);
// ==================== Comparison ====================
private static int CompareValues(FormulaResult a, FormulaResult b)
{
if (a.IsNumeric && b.IsNumeric) return a.NumericValue!.Value.CompareTo(b.NumericValue!.Value);
if (a.IsString && b.IsString) return string.Compare(a.StringValue, b.StringValue, StringComparison.OrdinalIgnoreCase);
if (a.IsBool && b.IsBool) return (a.BoolValue!.Value ? 1 : 0).CompareTo(b.BoolValue!.Value ? 1 : 0);
// Excel cross-type ordering: Number < Text < FALSE < TRUE. Critically,
// ="1"=1 is FALSE in Excel (text-vs-number never equal) — do NOT coerce
// via AsNumber here. AsNumber's text→number coercion is for arithmetic
// operators only; comparison operators preserve type identity.
int Rank(FormulaResult r) => r.IsNumeric ? 0 : r.IsString ? 1 : r.IsBool ? (r.BoolValue!.Value ? 3 : 2) : 1;
return Rank(a).CompareTo(Rank(b));
}
private static IEnumerable<FormulaResult> ExpandRange(RangeData rd) =>
Enumerable.Range(0, rd.Rows).SelectMany(r =>
Enumerable.Range(0, rd.Cols).Select(c => rd.Cells[r, c] ?? FormulaResult.Number(0)));
private static List<FormulaResult> AllArgs(List<object> args) =>
args.SelectMany(a => a is RangeData rd ? ExpandRange(rd)
: a is FormulaResult { IsRange: true } fr ? ExpandRange(fr.RangeValue!)
: a is double[] arr ? arr.Select(v => FormulaResult.Number(v))
: a is FormulaResult r ? [r] : Enumerable.Empty<FormulaResult>()).ToList();
/// <summary>Returns the first error found in any RangeData or FormulaResult arg, or null.</summary>
private static FormulaResult? CheckRangeErrors(List<object> args)
{
foreach (var a in args)
{
if (a is RangeData rd) { var err = rd.FirstError(); if (err != null) return err; }
else if (a is FormulaResult { IsRange: true } fr) { var err = fr.RangeValue!.FirstError(); if (err != null) return err; }
else if (a is FormulaResult { IsError: true } e) return e;
}
return null;
}
private static double[] FlattenNumbers(List<object> args)
{
var result = new List<double>();
foreach (var a in args)
{
if (a is RangeData rd) result.AddRange(rd.ToDoubleArray());
else if (a is FormulaResult { IsRange: true } fr) result.AddRange(fr.RangeValue!.ToDoubleArray());
else if (a is FormulaResult { IsArray: true } fa) result.AddRange(fa.ArrayValue!);
else if (a is double[] arr) result.AddRange(arr);
else if (a is FormulaResult { IsNumeric: true } r) result.Add(r.NumericValue!.Value);
else if (a is FormulaResult { IsBool: true } rb) result.Add(rb.BoolValue!.Value ? 1 : 0);
}
return result.ToArray();
}
// ==================== Criteria matching (for SUMIF, COUNTIF, etc.) ====================
private static bool MatchesCriteria(double value, string criteria)
=> MatchesCriteria(FormulaResult.Number(value), criteria);
private static bool MatchesCriteria(FormulaResult? cellValue, string criteria)
{
criteria = criteria.Trim();
if (string.IsNullOrEmpty(criteria)) return true;
// Numeric comparison operators
double numVal = cellValue?.AsNumber() ?? 0;
if (criteria.StartsWith(">=") && double.TryParse(criteria[2..], NumberStyles.Any, CultureInfo.InvariantCulture, out var ge)) return numVal >= ge;
if (criteria.StartsWith("<=") && double.TryParse(criteria[2..], NumberStyles.Any, CultureInfo.InvariantCulture, out var le)) return numVal <= le;
if (criteria.StartsWith("<>"))
{
var operand = criteria[2..];
if (double.TryParse(operand, NumberStyles.Any, CultureInfo.InvariantCulture, out var ne)) return Math.Abs(numVal - ne) > 1e-10;
// String not-equal
return !string.Equals(cellValue?.AsString() ?? "", operand, StringComparison.OrdinalIgnoreCase);
}
if (criteria.StartsWith(">") && double.TryParse(criteria[1..], NumberStyles.Any, CultureInfo.InvariantCulture, out var gt)) return numVal > gt;
if (criteria.StartsWith("<") && double.TryParse(criteria[1..], NumberStyles.Any, CultureInfo.InvariantCulture, out var lt)) return numVal < lt;
if (criteria.StartsWith("="))
{
var operand = criteria[1..];
if (double.TryParse(operand, NumberStyles.Any, CultureInfo.InvariantCulture, out var eq)) return Math.Abs(numVal - eq) < 1e-10;
// String equality after =
return string.Equals(cellValue?.AsString() ?? "", operand, StringComparison.OrdinalIgnoreCase);
}
if (double.TryParse(criteria, NumberStyles.Any, CultureInfo.InvariantCulture, out var plain)) return Math.Abs(numVal - plain) < 1e-10;
// Wildcard / string matching
string cellStr = cellValue?.AsString() ?? "";
if (criteria.Contains('*') || criteria.Contains('?'))
{
// Convert Excel wildcards to regex: * -> .*, ? -> ., ~* -> literal *, ~? -> literal ?
var pattern = Regex.Escape(criteria).Replace(@"\~\*", "\x01").Replace(@"\~\?", "\x02")
.Replace(@"\*", ".*").Replace(@"\?", ".").Replace("\x01", @"\*").Replace("\x02", @"\?");
return Regex.IsMatch(cellStr, "^" + pattern + "$", RegexOptions.IgnoreCase);
}
// Plain string equality
return string.Equals(cellStr, criteria, StringComparison.OrdinalIgnoreCase);
}
// ==================== Math utilities ====================
private static double RoundUp(double v, int d) { var f = Math.Pow(10, d); return Math.Ceiling(Math.Abs(v) * f) / f * Math.Sign(v); }
private static double RoundDown(double v, int d) { var f = Math.Pow(10, d); return Math.Floor(Math.Abs(v) * f) / f * Math.Sign(v); }
private static double CeilingF(double v, double s) => s == 0 ? 0 : Math.Ceiling(v / s) * s;
private static double FloorF(double v, double s) => s == 0 ? 0 : Math.Floor(v / s) * s;
private static double EvenF(double v) { var c = (int)Math.Ceiling(Math.Abs(v)); return (c % 2 == 0 ? c : c + 1) * Math.Sign(v); }
private static double OddF(double v) { var c = (int)Math.Ceiling(Math.Abs(v)); return (c % 2 == 1 ? c : c + 1) * Math.Sign(v); }
private static double Factorial(double n) { double r = 1; for (int i = 2; i <= (int)n; i++) r *= i; return r; }
private static double Combin(int n, int k) => k < 0 || k > n ? 0 : Factorial(n) / (Factorial(k) * Factorial(n - k));
private static double Permut(int n, int k) => k < 0 || k > n ? 0 : Factorial(n) / Factorial(n - k);
private static long Gcd(long a, long b) { a = Math.Abs(a); b = Math.Abs(b); while (b != 0) { var t = b; b = a % b; a = t; } return a; }
private static long Lcm(long a, long b) => a == 0 || b == 0 ? 0 : Math.Abs(a / Gcd(a, b) * b);
private static string ToRoman(int n)
{
var vals = new[] { 1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1 };
var syms = new[] { "M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I" };
var sb = new StringBuilder();
for (int i = 0; i < vals.Length; i++) while (n >= vals[i]) { sb.Append(syms[i]); n -= vals[i]; }
return sb.ToString();
}
private static double FromRoman(string s)
{
var map = new Dictionary<char, int> { ['M'] = 1000, ['D'] = 500, ['C'] = 100, ['L'] = 50, ['X'] = 10, ['V'] = 5, ['I'] = 1 };
double result = 0;
for (int i = 0; i < s.Length; i++)
{
var val = map.GetValueOrDefault(char.ToUpper(s[i]));
if (i + 1 < s.Length && val < map.GetValueOrDefault(char.ToUpper(s[i + 1]))) result -= val;
else result += val;
}
return result;
}
}
@@ -0,0 +1,224 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Unresolved cell or area reference, kept first-class so that OFFSET / INDIRECT
/// can manipulate the reference itself instead of receiving a dereferenced value.
/// Single-cell refs use Width=Height=1.
/// </summary>
internal record RefArg(string? Sheet, int Col, int Row, int Width, int Height);
internal partial class FormulaEvaluator
{
/// <summary>
/// Convert a token-level range expression like "A1:B3" (or "A:A", "1:1")
/// to a RefArg. Sheet-prefixed forms pass the sheet name in via parameter.
/// </summary>
private RefArg? BuildRefFromRange(string? sheet, string rangeExpr)
{
var parts = rangeExpr.Split(':');
if (parts.Length != 2) return null;
var left = StripDollar(parts[0]);
var right = StripDollar(parts[1]);
var leftColOnly = Regex.IsMatch(left, @"^[A-Z]+$", RegexOptions.IgnoreCase);
var rightColOnly = Regex.IsMatch(right, @"^[A-Z]+$", RegexOptions.IgnoreCase);
var leftRowOnly = Regex.IsMatch(left, @"^\d+$");
var rightRowOnly = Regex.IsMatch(right, @"^\d+$");
int r1, r2, c1, c2;
if (leftColOnly && rightColOnly)
{
c1 = ColToIndex(left.ToUpperInvariant());
c2 = ColToIndex(right.ToUpperInvariant());
var target = GetSheetDataFor(sheet);
if (target == null) return null;
var (minRow, maxRow) = GetPopulatedRowRange(target);
if (maxRow == 0) return null;
r1 = minRow; r2 = maxRow;
}
else if (leftRowOnly && rightRowOnly)
{
r1 = int.Parse(left); r2 = int.Parse(right);
var target = GetSheetDataFor(sheet);
if (target == null) return null;
var (minCol, maxCol) = GetPopulatedColRange(target);
if (maxCol == 0) return null;
c1 = minCol; c2 = maxCol;
}
else
{
var (col1, row1) = ParseRef(left);
var (col2, row2) = ParseRef(right);
c1 = ColToIndex(col1); c2 = ColToIndex(col2);
r1 = row1; r2 = row2;
}
var colMin = Math.Min(c1, c2); var colMax = Math.Max(c1, c2);
var rowMin = Math.Min(r1, r2); var rowMax = Math.Max(r1, r2);
// Excel sheet limits: rows 1..1048576, cols 1..16384 (XFD).
if (rowMin < 1 || rowMax > ExcelMaxRow) return null;
if (colMin < 1 || colMax > ExcelMaxCol) return null;
return new RefArg(sheet, colMin, rowMin, colMax - colMin + 1, rowMax - rowMin + 1);
}
/// <summary>
/// Parse a reference string (e.g. "A1", "Sheet1!B2", "A1:C3") into a RefArg.
/// Used by INDIRECT to convert its evaluated string argument into a reference.
/// </summary>
private RefArg? ParseRefString(string s)
{
// R3 BUG A: only trim ASCII space + tab. .Trim() (no args) strips ALL
// Unicode whitespace including NBSP (U+00A0) — Excel does NOT trim NBSP
// from INDIRECT's argument; an NBSP-padded ref must yield #REF!. We
// keep ASCII-space lenience because Round 1 chose that as a deliberate
// ergonomic deviation (`INDIRECT(" A1 ")` already worked and tests
// depend on it); NBSP and other Unicode whitespace fall through to
// IsCellRef, fail to match, and surface as #REF! naturally.
s = s.Trim(' ', '\t');
if (string.IsNullOrEmpty(s)) return null;
string? sheet = null;
var bang = s.IndexOf('!');
if (bang > 0)
{
sheet = s[..bang].Trim('\'');
s = s[(bang + 1)..];
}
s = StripDollar(s);
if (s.Contains(':')) return BuildRefFromRange(sheet, s);
if (IsCellRef(s))
{
var (col, row) = ParseRef(s);
var colIdx = ColToIndex(col);
// Excel sheet limits: row 1..1048576, col 1..16384 (XFD).
if (row < 1 || row > ExcelMaxRow) return null;
if (colIdx < 1 || colIdx > ExcelMaxCol) return null;
return new RefArg(sheet, colIdx, row, 1, 1);
}
return null;
}
/// <summary>
/// Resolve a RefArg to the actual cell values. Single-cell → scalar
/// FormulaResult; multi-cell → FormulaResult.Area wrapping a RangeData.
/// </summary>
private FormulaResult? ResolveRef(RefArg r)
{
var rangeMemoKey = r.Height * r.Width > 1
? $"{r.Sheet ?? _sheetKey}|{r.Col},{r.Row},{r.Width},{r.Height}"
: null;
if (rangeMemoKey != null && _session.RangeMemo.TryGetValue(rangeMemoKey, out var memoRange))
return FormulaResult.Area(memoRange);
var circularBefore = _session.CircularHits;
var cells = new FormulaResult?[r.Height, r.Width];
for (int dr = 0; dr < r.Height; dr++)
for (int dc = 0; dc < r.Width; dc++)
{
var cellRef = $"{IndexToCol(r.Col + dc)}{r.Row + dr}";
cells[dr, dc] = r.Sheet != null
? ResolveSheetCellResult($"{r.Sheet}!{cellRef}")
: ResolveCellResult(cellRef);
}
// R16-1: for a single-cell ref whose resolved value is an error (e.g. a
// depth-guard #NUM! from a deep INDIRECT/OFFSET cross-sheet chain),
// return the error UNWRAPPED. Wrapping it in an Area hides it —
// Area.IsError is false and Area.AsNumber() coerces the error cell to 0
// via FirstCell(), so arithmetic (INDIRECT(...)+1) silently produced a
// wrong number instead of propagating the error. Returning it directly
// lets ApplyBinaryOp see IsError=true and propagate #NUM! up the chain.
if (r.Height == 1 && r.Width == 1 && cells[0, 0]?.IsError == true)
return cells[0, 0];
// Otherwise always return an Area, even for single-cell refs. This
// preserves the origin row/col so ROW(OFFSET(...)) / COLUMN(OFFSET(...)) /
// ADDRESS can answer correctly. Single-cell consumers (AsNumber, AsString)
// transparently peek the lone cell via FirstCell() in FormulaResult.
var range = new RangeData(cells) { BaseRow = r.Row, BaseCol = r.Col, BaseSheet = r.Sheet };
// Same cycle-taint rule as CellMemo: a rect whose materialization tripped
// the circular-ref fallback holds entry-point-dependent seed values.
if (rangeMemoKey != null && circularBefore == _session.CircularHits)
_session.RangeMemo[rangeMemoKey] = range;
return FormulaResult.Area(range);
}
/// <summary>
/// OFFSET(reference, rows, cols, [height], [width]).
/// Returns the value at the offset position (single cell) or an Area result
/// (multi-cell). Outer functions like SUM/AVERAGE consume the Area through
/// the IsRange handling in helpers.
/// </summary>
private const int ExcelMaxRow = 1048576;
private const int ExcelMaxCol = 16384;
/// <summary>Coerce a FormulaResult to a number, accepting numeric strings ("1", "2.5").</summary>
private static double CoerceToNumber(FormulaResult? r)
{
if (r == null) return 0;
if (r.IsString && double.TryParse(r.StringValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
return v;
return r.AsNumber();
}
private FormulaResult? EvalOffset(List<object> args)
{
if (args.Count < 3 || args.Count > 5) return FormulaResult.Error("#VALUE!");
// Accept either a RefArg (literal cell/range token captured by
// TryParseRefArg) OR a FormulaResult.Area whose underlying RangeData
// carries BaseRow/BaseCol — produced when a previous OFFSET / INDIRECT
// returned an Area, or when a defined-name body inlined to such a call.
// This lets nested OFFSET(OFFSET(...), ...) and three-level defined-name
// OFFSET chains resolve.
RefArg baseRef;
if (args[0] is RefArg ra) baseRef = ra;
else if (args[0] is FormulaResult fra && fra.IsRange &&
fra.RangeValue is { BaseRow: > 0, BaseCol: > 0 } rd)
baseRef = new RefArg(rd.BaseSheet, rd.BaseCol, rd.BaseRow, rd.Cols, rd.Rows);
else return FormulaResult.Error("#VALUE!");
// Bug 1: propagate any error in row/col/height/width before consuming.
for (int i = 1; i < args.Count; i++)
{
if (args[i] is FormulaResult fr && fr.IsError) return fr;
}
// Bug 7: numeric strings coerce to numbers.
int rowOffset = (int)CoerceToNumber(args[1] as FormulaResult);
int colOffset = (int)CoerceToNumber(args[2] as FormulaResult);
int height = baseRef.Height;
int width = baseRef.Width;
if (args.Count >= 4 && args[3] is FormulaResult hArg) height = (int)CoerceToNumber(hArg);
if (args.Count >= 5 && args[4] is FormulaResult wArg) width = (int)CoerceToNumber(wArg);
if (height == 0 || width == 0) return FormulaResult.Error("#REF!");
var newRow = baseRef.Row + rowOffset;
var newCol = baseRef.Col + colOffset;
if (height < 0) { newRow += height + 1; height = -height; }
if (width < 0) { newCol += width + 1; width = -width; }
if (newRow < 1 || newCol < 1) return FormulaResult.Error("#REF!");
// Excel sheet limits: rows 1..1048576, cols 1..16384 (XFD).
if (newRow > ExcelMaxRow || newCol > ExcelMaxCol) return FormulaResult.Error("#REF!");
if (newRow + height - 1 > ExcelMaxRow || newCol + width - 1 > ExcelMaxCol) return FormulaResult.Error("#REF!");
return ResolveRef(new RefArg(baseRef.Sheet, newCol, newRow, width, height));
}
/// <summary>
/// INDIRECT(ref_text). Only the A1-style form is supported (the [a1] argument
/// is accepted but ignored — R1C1 syntax is not implemented).
/// </summary>
private FormulaResult? EvalIndirect(List<object> args)
{
if (args.Count < 1) return FormulaResult.Error("#VALUE!");
// Propagate the original error rather than treating its text as a ref.
if (args[0] is FormulaResult { IsError: true } e) return e;
var s = (args[0] as FormulaResult)?.AsString();
if (string.IsNullOrEmpty(s)) return FormulaResult.Error("#REF!");
var refArg = ParseRefString(s);
if (refArg == null) return FormulaResult.Error("#REF!");
return ResolveRef(refArg);
}
}
@@ -0,0 +1,276 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
// W4d — the array-returning regression family (LINEST / LOGEST / TREND / GROWTH).
// Each spills: LINEST/LOGEST return the coefficient row (Excel order
// {m_k,…,m_1,b}) optionally with the 5-row stats block; TREND/GROWTH return the
// fitted values. The dynamic-array writeback (ExcelHandler.DynamicArray) makes
// Excel spill them; the anchor (top-left) is the cell's computedValue. Verified
// against real Microsoft Excel.
internal partial class FormulaEvaluator
{
// Ordinary least squares: solve (XᵀX)β = Xᵀy. Returns β (length p) or null
// when the normal-equation system is singular.
private static double[]? LeastSquares(double[,] x, double[] y, out double[,]? xtxInv)
{
xtxInv = null;
int n = x.GetLength(0), p = x.GetLength(1);
var a = new double[p, p];
var g = new double[p];
for (int i = 0; i < p; i++)
{
for (int j = 0; j < p; j++)
{
double s = 0;
for (int k = 0; k < n; k++) s += x[k, i] * x[k, j];
a[i, j] = s;
}
double gi = 0;
for (int k = 0; k < n; k++) gi += x[k, i] * y[k];
g[i] = gi;
}
var inv = Invert(a);
if (inv == null) return null;
xtxInv = inv;
var beta = new double[p];
for (int i = 0; i < p; i++)
{
double s = 0;
for (int j = 0; j < p; j++) s += inv[i, j] * g[j];
beta[i] = s;
}
return beta;
}
// Gauss-Jordan inverse with partial pivoting; null when singular.
private static double[,]? Invert(double[,] m)
{
int n = m.GetLength(0);
var a = new double[n, 2 * n];
for (int i = 0; i < n; i++) { for (int j = 0; j < n; j++) a[i, j] = m[i, j]; a[i, n + i] = 1; }
for (int col = 0; col < n; col++)
{
int piv = col;
for (int r = col + 1; r < n; r++) if (Math.Abs(a[r, col]) > Math.Abs(a[piv, col])) piv = r;
if (Math.Abs(a[piv, col]) < 1e-300) return null;
if (piv != col) for (int j = 0; j < 2 * n; j++) (a[col, j], a[piv, j]) = (a[piv, j], a[col, j]);
double d = a[col, col];
for (int j = 0; j < 2 * n; j++) a[col, j] /= d;
for (int r = 0; r < n; r++)
{
if (r == col) continue;
double f = a[r, col];
for (int j = 0; j < 2 * n; j++) a[r, j] -= f * a[col, j];
}
}
var inv = new double[n, n];
for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) inv[i, j] = a[i, n + j];
return inv;
}
// A real range/array argument (known_x, new_x): arrives as a RangeData (from
// the function-arg range interception) or an Area/Array FormulaResult — NOT a
// plain FormulaResult. Bare scalars (e.g. an omitted-slot 0) are not grids.
private static bool HasGridArg(List<object> args, int i)
=> i < args.Count && (args[i] is RangeData || args[i] is FormulaResult { IsRange: true } or FormulaResult { IsArray: true });
// Pull (y vector, X matrix n×k of independent variables) from known_y /
// optional known_x, normalizing orientation to n rows.
private bool RegressionInputs(List<object> args, out double[] y, out double[][] xcols)
{
y = Array.Empty<double>(); xcols = Array.Empty<double[]>();
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } yg) return false;
y = Flatten(yg).Select(c => c?.AsNumber() ?? 0).ToArray();
int n = y.Length;
if (n == 0) return false;
if (HasGridArg(args, 1) && ToGrid(args[1]) is { } xg)
{
int xr = xg.GetLength(0), xc = xg.GetLength(1);
// Variables run along the dimension that is NOT length n. When the
// block is n×k, each column is a variable; when k×n, each row is.
if (xr == n)
{
xcols = new double[xc][];
for (int c = 0; c < xc; c++)
{
xcols[c] = new double[n];
for (int r = 0; r < n; r++) xcols[c][r] = xg[r, c]?.AsNumber() ?? 0;
}
}
else if (xc == n)
{
xcols = new double[xr][];
for (int v = 0; v < xr; v++)
{
xcols[v] = new double[n];
for (int r = 0; r < n; r++) xcols[v][r] = xg[v, r]?.AsNumber() ?? 0;
}
}
else return false;
}
else
{
// Omitted known_x → the sequence {1,2,…,n}.
var seq = new double[n];
for (int i = 0; i < n; i++) seq[i] = i + 1;
xcols = new[] { seq };
}
return true;
}
// Build the design matrix (independent columns + optional trailing intercept
// column of ones) and fit. coeffs come back in NATURAL order [m1,…,mk,(b)].
private double[]? FitLinear(double[] y, double[][] xcols, bool withConst, out double[,]? xtxInv, out int k)
{
k = xcols.Length;
int n = y.Length;
int p = k + (withConst ? 1 : 0);
var x = new double[n, p];
for (int r = 0; r < n; r++)
{
for (int c = 0; c < k; c++) x[r, c] = xcols[c][r];
if (withConst) x[r, k] = 1.0;
}
return LeastSquares(x, y, out xtxInv);
}
private FormulaResult? EvalLinest(List<object> args, bool log)
{
if (!RegressionInputs(args, out var y, out var xcols)) return FormulaResult.Error("#VALUE!");
bool withConst = args.Count <= 2 || args[2] is not FormulaResult c2 || c2.IsBlank || c2.AsNumber() != 0;
bool stats = args.Count > 3 && args[3] is FormulaResult s3 && !s3.IsBlank && s3.AsNumber() != 0;
double[] fitY = y;
if (log)
{
if (y.Any(v => v <= 0)) return FormulaResult.Error("#NUM!");
fitY = y.Select(v => Math.Log(v)).ToArray();
}
var beta = FitLinear(fitY, xcols, withConst, out var xtxInv, out int k);
if (beta is null) return FormulaResult.Error("#NUM!");
// Excel order: {m_k, …, m_1, b}. Natural order is [m1..mk,(b)].
var slopes = new double[k];
for (int i = 0; i < k; i++) slopes[i] = beta[i];
double b0 = withConst ? beta[k] : 0.0;
var coeff = new double[k + 1];
for (int i = 0; i < k; i++) coeff[i] = slopes[k - 1 - i]; // reversed slopes
coeff[k] = b0;
var logCoeff = log ? coeff.Select((v, i) => i < k ? Math.Exp(v) : Math.Exp(v)).ToArray() : coeff;
if (!stats)
{
var row = new FormulaResult?[1, k + 1];
for (int i = 0; i <= k; i++) row[0, i] = FormulaResult.Number(logCoeff[i]);
return MakeArea(row);
}
// Full 5-row stats block (coefficients, SEs, [r2, sey], [F, df], [ssreg, ssresid]).
int n = fitY.Length, p = k + (withConst ? 1 : 0);
var pred = new double[n];
for (int r = 0; r < n; r++)
{
double v = withConst ? beta[k] : 0;
for (int c = 0; c < k; c++) v += beta[c] * xcols[c][r];
pred[r] = v;
}
double meanY = fitY.Average();
double ssTot = withConst ? fitY.Sum(v => (v - meanY) * (v - meanY)) : fitY.Sum(v => v * v);
double ssResid = 0; for (int r = 0; r < n; r++) ssResid += (fitY[r] - pred[r]) * (fitY[r] - pred[r]);
double ssReg = ssTot - ssResid;
int dfResid = n - p;
double r2 = ssTot == 0 ? 1 : 1 - ssResid / ssTot;
double sey = dfResid > 0 ? Math.Sqrt(ssResid / dfResid) : 0;
double fStat = (dfResid > 0 && ssResid > 0) ? (ssReg / k) / (ssResid / dfResid) : double.PositiveInfinity;
// Coefficient standard errors: sey * sqrt(diag((XᵀX)⁻¹)), same reversal.
var se = new double[k + 1];
if (xtxInv != null)
{
var seNat = new double[p];
for (int i = 0; i < p; i++) seNat[i] = sey * Math.Sqrt(Math.Max(0, xtxInv[i, i]));
for (int i = 0; i < k; i++) se[i] = seNat[k - 1 - i];
se[k] = withConst ? seNat[k] : double.NaN;
}
var na = FormulaResult.Error("#N/A");
var grid = new FormulaResult?[5, k + 1];
for (int i = 0; i <= k; i++) grid[0, i] = FormulaResult.Number(logCoeff[i]);
for (int i = 0; i <= k; i++) grid[1, i] = withConst || i < k ? FormulaResult.Number(se[i]) : na;
grid[2, 0] = FormulaResult.Number(r2); grid[2, 1] = FormulaResult.Number(log ? Math.Exp(sey) : sey);
for (int i = 2; i <= k; i++) grid[2, i] = na;
grid[3, 0] = FormulaResult.Number(fStat); grid[3, 1] = FormulaResult.Number(dfResid);
for (int i = 2; i <= k; i++) grid[3, i] = na;
grid[4, 0] = FormulaResult.Number(ssReg); grid[4, 1] = FormulaResult.Number(ssResid);
for (int i = 2; i <= k; i++) grid[4, i] = na;
return MakeArea(grid);
}
// TREND / GROWTH(known_y, [known_x], [new_x], [const]) — fitted predictions.
private FormulaResult? EvalTrend(List<object> args, bool log)
{
if (!RegressionInputs(args, out var y, out var xcols)) return FormulaResult.Error("#VALUE!");
bool withConst = args.Count <= 3 || args[3] is not FormulaResult c3 || c3.IsBlank || c3.AsNumber() != 0;
int k = xcols.Length, n = y.Length;
double[] fitY = y;
if (log)
{
if (y.Any(v => v <= 0)) return FormulaResult.Error("#NUM!");
fitY = y.Select(v => Math.Log(v)).ToArray();
}
var beta = FitLinear(fitY, xcols, withConst, out _, out _);
if (beta is null) return FormulaResult.Error("#NUM!");
// new_x: a range/array OR a scalar; absent → predict over known_x.
bool hasNewX = args.Count > 2 && (args[2] is RangeData || args[2] is FormulaResult { IsBlank: false });
FormulaResult?[,]? newGrid = hasNewX && ToGrid(args[2]) is { } ng ? ng : null;
double[][] predCols; int m; FormulaResult?[,] outShape;
if (newGrid == null)
{
predCols = xcols; m = n;
outShape = new FormulaResult?[n, 1];
}
else
{
int nr = newGrid.GetLength(0), nc = newGrid.GetLength(1);
if (k == 1)
{
m = nr * nc;
predCols = new[] { new double[m] };
int idx = 0;
for (int r = 0; r < nr; r++) for (int c = 0; c < nc; c++) predCols[0][idx++] = newGrid[r, c]?.AsNumber() ?? 0;
outShape = new FormulaResult?[nr, nc];
}
else if (nr == k) { m = nc; predCols = ToCols(newGrid, k, m, byRow: true); outShape = new FormulaResult?[1, m]; }
else if (nc == k) { m = nr; predCols = ToCols(newGrid, k, m, byRow: false); outShape = new FormulaResult?[m, 1]; }
else return FormulaResult.Error("#REF!");
}
var preds = new double[m];
for (int i = 0; i < m; i++)
{
double v = withConst ? beta[k] : 0;
for (int c = 0; c < k; c++) v += beta[c] * predCols[c][i];
preds[i] = log ? Math.Exp(v) : v;
}
// Fill outShape row-major with preds.
int orows = outShape.GetLength(0), ocols = outShape.GetLength(1), pi = 0;
for (int r = 0; r < orows; r++) for (int c = 0; c < ocols; c++) outShape[r, c] = FormulaResult.Number(preds[pi++]);
return MakeArea(outShape);
}
private static double[][] ToCols(FormulaResult?[,] g, int k, int m, bool byRow)
{
var cols = new double[k][];
for (int v = 0; v < k; v++)
{
cols[v] = new double[m];
for (int i = 0; i < m; i++) cols[v][i] = (byRow ? g[v, i] : g[i, v])?.AsNumber() ?? 0;
}
return cols;
}
}
@@ -0,0 +1,411 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
namespace OfficeCli.Core;
// W3b — bond / coupon / securities analysis functions (the date-driven
// fixed-income family). Every one rests on two shared pieces: the day-count
// basis engine (DayCountDiff / YearFracBasis, bases 04) and the coupon-date
// schedule (CouponPcd / CouponNcd stepped back from maturity). PRICE/YIELD-style
// roots reuse the shared SolveRoot. Values are verified against real Microsoft
// Excel — the day-count edge rules (30/360 end-of-month, actual/actual) are
// where engines diverge, so each function is checked rather than trusted.
internal partial class FormulaEvaluator
{
// ---- argument coercion ----
private static DateTime Serial(List<object> args, int i)
=> DateTime.FromOADate(i < args.Count && args[i] is FormulaResult r ? r.AsNumber() : 0);
private static double SecNum(List<object> args, int i, double def = 0)
=> i < args.Count && args[i] is FormulaResult r && !r.IsBlank ? r.AsNumber() : def;
private static int SecBasis(List<object> args, int i)
{
int b = (int)SecNum(args, i, 0);
return b;
}
// ---- day-count basis engine (bases 04) ----
private static bool IsLastDayOfMonth(DateTime d) => d.Day == DateTime.DaysInMonth(d.Year, d.Month);
private static bool IsLastFeb(DateTime d) => d.Month == 2 && IsLastDayOfMonth(d);
// US (NASD) 30/360 — Excel basis 0.
private static int Days360Us(DateTime d1, DateTime d2)
{
int dd1 = d1.Day, dd2 = d2.Day;
if (IsLastFeb(d1) && IsLastFeb(d2)) dd2 = 30;
if (IsLastFeb(d1)) dd1 = 30;
if (dd2 == 31 && dd1 >= 30) dd2 = 30;
if (dd1 == 31) dd1 = 30;
return (d2.Year - d1.Year) * 360 + (d2.Month - d1.Month) * 30 + (dd2 - dd1);
}
// European 30/360 — Excel basis 4.
private static int Days360Eu(DateTime d1, DateTime d2)
{
int dd1 = Math.Min(d1.Day, 30), dd2 = Math.Min(d2.Day, 30);
return (d2.Year - d1.Year) * 360 + (d2.Month - d1.Month) * 30 + (dd2 - dd1);
}
// Day count between two dates under a basis.
private static double DayCountDiff(DateTime d1, DateTime d2, int basis) => basis switch
{
0 => Days360Us(d1, d2),
4 => Days360Eu(d1, d2),
_ => (d2 - d1).Days, // actual (bases 1,2,3)
};
private static double DaysInYearBasis(int basis) => basis == 3 ? 365.0 : 360.0;
// Year fraction between two dates under a basis (YEARFRAC + ACCRINT engine).
private static double YearFracBasis(DateTime start, DateTime end, int basis)
{
if (start == end) return 0.0;
bool neg = start > end;
if (neg) (start, end) = (end, start);
double frac = basis switch
{
0 => Days360Us(start, end) / 360.0,
4 => Days360Eu(start, end) / 360.0,
2 => (end - start).Days / 360.0,
3 => (end - start).Days / 365.0,
_ => ActualActualYearFrac(start, end), // basis 1
};
return neg ? -frac : frac;
}
// Excel's basis-1 actual/actual: divide actual days by the average days-per-
// year over the calendar years the interval spans.
private static double ActualActualYearFrac(DateTime start, DateTime end)
{
int y1 = start.Year, y2 = end.Year;
double days = (end - start).Days;
if (y1 == y2)
return days / (DateTime.IsLeapYear(y1) ? 366.0 : 365.0);
int yearsSpanned = y2 - y1 + 1;
double totalDays = (new DateTime(y2 + 1, 1, 1) - new DateTime(y1, 1, 1)).Days;
double avg = totalDays / yearsSpanned;
return days / avg;
}
// ---- coupon schedule ----
// Coupon date <= settlement (previous coupon date), generated by stepping the
// maturity date back by whole periods with end-of-month preservation.
private static DateTime CouponPcd(DateTime settle, DateTime maturity, int freq)
{
int step = 12 / freq;
bool eom = IsLastDayOfMonth(maturity);
DateTime d = maturity;
int k = 0;
while (d > settle) { k++; d = ShiftMonthsEom(maturity, -k * step, eom); }
return d;
}
// Coupon date > settlement (next coupon date).
private static DateTime CouponNcd(DateTime settle, DateTime maturity, int freq)
{
int step = 12 / freq;
bool eom = IsLastDayOfMonth(maturity);
DateTime d = maturity;
int k = 0;
while (d > settle) { k++; d = ShiftMonthsEom(maturity, -k * step, eom); }
// d is now the last coupon <= settle; one step forward is the NCD.
return ShiftMonthsEom(maturity, -(k - 1) * step, eom);
}
private static DateTime ShiftMonthsEom(DateTime anchor, int months, bool eom)
{
int total = (anchor.Year * 12 + (anchor.Month - 1)) + months;
int y = total / 12, m = total % 12 + 1;
int dim = DateTime.DaysInMonth(y, m);
int day = eom ? dim : Math.Min(anchor.Day, dim);
return new DateTime(y, m, day);
}
private static int CouponNumber(DateTime settle, DateTime maturity, int freq)
{
int step = 12 / freq;
bool eom = IsLastDayOfMonth(maturity);
int count = 0;
DateTime d = maturity;
int k = 0;
while (d > settle) { count++; k++; d = ShiftMonthsEom(maturity, -k * step, eom); }
return count;
}
// COUPDAYS — days in the coupon period containing settlement.
private FormulaResult? EvalCoupDays(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out var b)) return FormulaResult.Error("#NUM!");
double days = b == 1
? (CouponNcd(s, m, f) - CouponPcd(s, m, f)).Days
: DaysInYearBasis(b) / f;
return FR(days);
}
private FormulaResult? EvalCoupDayBs(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out var b)) return FormulaResult.Error("#NUM!");
return FR(DayCountDiff(CouponPcd(s, m, f), s, b));
}
private FormulaResult? EvalCoupDaysNc(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out var b)) return FormulaResult.Error("#NUM!");
// 30/360 bases derive NC from the period less the elapsed part; actual
// bases count the real days settlement→next coupon.
if (b is 0 or 4)
return FR(DaysInYearBasis(b) / f - DayCountDiff(CouponPcd(s, m, f), s, b));
return FR(DayCountDiff(s, CouponNcd(s, m, f), b));
}
private FormulaResult? EvalCoupNcd(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out _)) return FormulaResult.Error("#NUM!");
return FR(CouponNcd(s, m, f).ToOADate());
}
private FormulaResult? EvalCoupPcd(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out _)) return FormulaResult.Error("#NUM!");
return FR(CouponPcd(s, m, f).ToOADate());
}
private FormulaResult? EvalCoupNum(List<object> args)
{
if (!CoupArgs(args, out var s, out var m, out var f, out _)) return FormulaResult.Error("#NUM!");
return FR(CouponNumber(s, m, f));
}
private static bool CoupArgs(List<object> args, out DateTime settle, out DateTime maturity, out int freq, out int basis)
{
settle = Serial(args, 0); maturity = Serial(args, 1);
freq = (int)SecNum(args, 2, 0); basis = SecBasis(args, 3);
return (freq is 1 or 2 or 4) && basis is >= 0 and <= 4 && settle < maturity;
}
// ---- discount / interest securities ----
// ACCRINT(issue, first_interest, settlement, rate, par, frequency, [basis], [calc])
private FormulaResult? EvalAccrInt(List<object> args)
{
if (args.Count < 6) return FormulaResult.Error("#VALUE!");
var issue = Serial(args, 0);
var settle = Serial(args, 2);
double rate = SecNum(args, 3), par = SecNum(args, 4, 1000);
int freq = (int)SecNum(args, 5, 1), basis = SecBasis(args, 6);
if (rate <= 0 || par <= 0 || freq is not (1 or 2 or 4) || settle <= issue)
return FormulaResult.Error("#NUM!");
// From-issue accrual (the common default): par * rate * yearfrac(issue, settlement).
return FR(par * rate * YearFracBasis(issue, settle, basis));
}
// ACCRINTM(issue, settlement, rate, par, [basis])
private FormulaResult? EvalAccrIntM(List<object> args)
{
if (args.Count < 3) return FormulaResult.Error("#VALUE!");
var issue = Serial(args, 0); var settle = Serial(args, 1);
double rate = SecNum(args, 2), par = SecNum(args, 3, 1000);
int basis = SecBasis(args, 4);
if (rate <= 0 || par <= 0 || settle <= issue) return FormulaResult.Error("#NUM!");
return FR(par * rate * YearFracBasis(issue, settle, basis));
}
// DISC(settlement, maturity, pr, redemption, [basis])
private FormulaResult? EvalDisc(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double pr = SecNum(args, 2), red = SecNum(args, 3);
int basis = SecBasis(args, 4);
if (pr <= 0 || red <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dfrac = YearFracBasis(s, m, basis);
return FR((red - pr) / red / dfrac);
}
// INTRATE(settlement, maturity, investment, redemption, [basis])
private FormulaResult? EvalIntRate(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double inv = SecNum(args, 2), red = SecNum(args, 3);
int basis = SecBasis(args, 4);
if (inv <= 0 || red <= 0 || s >= m) return FormulaResult.Error("#NUM!");
return FR((red - inv) / inv / YearFracBasis(s, m, basis));
}
// RECEIVED(settlement, maturity, investment, discount, [basis])
private FormulaResult? EvalReceived(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double inv = SecNum(args, 2), disc = SecNum(args, 3);
int basis = SecBasis(args, 4);
if (inv <= 0 || disc <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dfrac = YearFracBasis(s, m, basis);
double denom = 1 - disc * dfrac;
if (denom <= 0) return FormulaResult.Error("#NUM!");
return FR(inv / denom);
}
// PRICEDISC(settlement, maturity, discount, redemption, [basis])
private FormulaResult? EvalPriceDisc(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double disc = SecNum(args, 2), red = SecNum(args, 3);
int basis = SecBasis(args, 4);
if (disc <= 0 || red <= 0 || s >= m) return FormulaResult.Error("#NUM!");
return FR(red - disc * red * YearFracBasis(s, m, basis));
}
// YIELDDISC(settlement, maturity, pr, redemption, [basis])
private FormulaResult? EvalYieldDisc(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double pr = SecNum(args, 2), red = SecNum(args, 3);
int basis = SecBasis(args, 4);
if (pr <= 0 || red <= 0 || s >= m) return FormulaResult.Error("#NUM!");
return FR((red - pr) / pr / YearFracBasis(s, m, basis));
}
// PRICEMAT(settlement, maturity, issue, rate, yld, [basis])
private FormulaResult? EvalPriceMat(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1); var issue = Serial(args, 2);
double rate = SecNum(args, 3), yld = SecNum(args, 4);
int basis = SecBasis(args, 5);
if (rate < 0 || yld < 0 || s >= m) return FormulaResult.Error("#NUM!");
double dim = YearFracBasis(issue, m, basis); // issue→maturity
double dis = YearFracBasis(issue, s, basis); // issue→settlement
double dsm = YearFracBasis(s, m, basis); // settlement→maturity
double num = 100 + dim * rate * 100;
double den = 1 + dsm * yld;
return FR(num / den - dis * rate * 100);
}
// YIELDMAT(settlement, maturity, issue, rate, pr, [basis])
private FormulaResult? EvalYieldMat(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1); var issue = Serial(args, 2);
double rate = SecNum(args, 3), pr = SecNum(args, 4);
int basis = SecBasis(args, 5);
if (rate < 0 || pr <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dim = YearFracBasis(issue, m, basis);
double dis = YearFracBasis(issue, s, basis);
double dsm = YearFracBasis(s, m, basis);
double a = 100 + dim * rate * 100;
double b = pr + dis * rate * 100;
return FR((a - b) / b / dsm);
}
// ---- T-bill family (always actual/360) ----
private FormulaResult? EvalTBillEq(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double disc = SecNum(args, 2);
if (disc <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dsm = (m - s).Days;
if (dsm > 365) return FormulaResult.Error("#NUM!");
return FR(365 * disc / (360 - disc * dsm));
}
private FormulaResult? EvalTBillPrice(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double disc = SecNum(args, 2);
if (disc <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dsm = (m - s).Days;
if (dsm > 365) return FormulaResult.Error("#NUM!");
return FR(100 * (1 - disc * dsm / 360));
}
private FormulaResult? EvalTBillYield(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double pr = SecNum(args, 2);
if (pr <= 0 || s >= m) return FormulaResult.Error("#NUM!");
double dsm = (m - s).Days;
if (dsm > 365) return FormulaResult.Error("#NUM!");
return FR((100 - pr) / pr * (360 / dsm));
}
// ---- coupon bond PRICE / YIELD / DURATION ----
// PRICE(settlement, maturity, rate, yld, redemption, frequency, [basis])
private FormulaResult? EvalPrice(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double rate = SecNum(args, 2), yld = SecNum(args, 3), red = SecNum(args, 4);
int freq = (int)SecNum(args, 5, 0), basis = SecBasis(args, 6);
if (rate < 0 || yld < 0 || red <= 0 || freq is not (1 or 2 or 4) || s >= m)
return FormulaResult.Error("#NUM!");
return FR(BondPrice(s, m, rate, yld, red, freq, basis));
}
// YIELD(settlement, maturity, rate, pr, redemption, frequency, [basis]) — root of PRICEpr.
private FormulaResult? EvalYield(List<object> args)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double rate = SecNum(args, 2), pr = SecNum(args, 3), red = SecNum(args, 4);
int freq = (int)SecNum(args, 5, 0), basis = SecBasis(args, 6);
if (rate < 0 || pr <= 0 || red <= 0 || freq is not (1 or 2 or 4) || s >= m)
return FormulaResult.Error("#NUM!");
var root = SolveRoot(y => BondPrice(s, m, rate, y, red, freq, basis) - pr, rate > 0 ? rate : 0.05);
return root is { } r ? FR(r) : FormulaResult.Error("#NUM!");
}
// Standard coupon-bond price per 100 face, with fractional first period.
private static double BondPrice(DateTime s, DateTime m, double rate, double yld, double red, int freq, int basis)
{
int n = CouponNumber(s, m, freq);
double e = basis == 1 ? (CouponNcd(s, m, freq) - CouponPcd(s, m, freq)).Days : DaysInYearBasis(basis) / freq;
double dsc = basis == 1 ? (CouponNcd(s, m, freq) - s).Days : e - DayCountDiff(CouponPcd(s, m, freq), s, basis);
double a = DayCountDiff(CouponPcd(s, m, freq), s, basis);
double t = dsc / e;
double coupon = 100.0 * rate / freq;
double yf = yld / freq;
double price = red / Math.Pow(1 + yf, n - 1 + t);
for (int k = 1; k <= n; k++)
price += coupon / Math.Pow(1 + yf, k - 1 + t);
price -= coupon * (a / e); // less accrued interest
return price;
}
// DURATION / MDURATION(settlement, maturity, coupon, yld, frequency, [basis])
private FormulaResult? EvalDuration(List<object> args, bool modified)
{
var s = Serial(args, 0); var m = Serial(args, 1);
double coupon = SecNum(args, 2), yld = SecNum(args, 3);
int freq = (int)SecNum(args, 4, 0), basis = SecBasis(args, 5);
if (coupon < 0 || yld < 0 || freq is not (1 or 2 or 4) || s >= m)
return FormulaResult.Error("#NUM!");
double dur = MacaulayDuration(s, m, coupon, yld, freq, basis);
if (modified) dur /= 1 + yld / freq;
return FR(dur);
}
private static double MacaulayDuration(DateTime s, DateTime m, double coupon, double yld, int freq, int basis)
{
int n = CouponNumber(s, m, freq);
double e = basis == 1 ? (CouponNcd(s, m, freq) - CouponPcd(s, m, freq)).Days : DaysInYearBasis(basis) / freq;
double dsc = basis == 1 ? (CouponNcd(s, m, freq) - s).Days : e - DayCountDiff(CouponPcd(s, m, freq), s, basis);
double t0 = dsc / e;
double yf = yld / freq;
double cpn = 100.0 * coupon / freq;
double wPv = 0, pv = 0;
for (int k = 1; k <= n; k++)
{
double tk = (k - 1 + t0); // periods to cashflow
double cf = cpn + (k == n ? 100.0 : 0.0); // redemption at par with final coupon
double d = cf / Math.Pow(1 + yf, tk);
pv += d;
wPv += d * (tk / freq); // weight in years
}
return pv == 0 ? 0 : wPv / pv;
}
}
@@ -0,0 +1,88 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Iterative root solver ====================
//
// Shared foundation for every Excel function whose value is the root of an
// equation rather than a closed form: RATE / IRR / XIRR / MIRR-style yield,
// and later YIELD / ODDFYIELD / etc. One solver, mirrored on Excel's
// approach: seeded Newton, bounded iterations, #NUM! on non-convergence.
//
// Newton with a numerical derivative is the primary method (matches Excel's
// quadratic convergence on smooth NPV curves); a bisection fallback over an
// expanding bracket recovers the cases where Newton's derivative vanishes or
// the iterate escapes the convergence basin (multiple sign changes, deep
// out-of-the-money cashflows). Returns null when neither converges — callers
// surface that as #NUM!, exactly like Excel.
private const int SolverMaxIter = 100;
private const double SolverTol = 1e-10;
/// <summary>
/// Find x such that f(x) ≈ 0, seeded at <paramref name="guess"/>. Newton
/// first (numerical derivative), then an expanding-bracket bisection fallback
/// clamped to the rate domain (-1, ∞). Null = no root found → caller emits
/// #NUM!. The fallback matters: real Excel's IRR/RATE recover far in-domain
/// roots that naive bounded Newton overshoots (e.g. a never-recoups series
/// solving to ≈ -42%), so the fallback is what keeps us equal to Excel's
/// cached value rather than a spreadsheet engine that gives up there.
/// </summary>
private static double? SolveRoot(Func<double, double> f, double guess)
{
double x = guess;
for (int i = 0; i < SolverMaxIter; i++)
{
double fx = f(x);
if (double.IsNaN(fx) || double.IsInfinity(fx)) break;
if (Math.Abs(fx) < SolverTol) return x;
// Central numerical derivative; step scales with |x| for conditioning.
double h = Math.Max(1e-7, Math.Abs(x) * 1e-7);
double dfx = (f(x + h) - f(x - h)) / (2 * h);
if (double.IsNaN(dfx) || double.IsInfinity(dfx) || Math.Abs(dfx) < 1e-14) break;
double next = x - fx / dfx;
if (double.IsNaN(next) || double.IsInfinity(next)) break;
if (Math.Abs(next - x) < SolverTol) return next;
x = next;
}
return BisectFallback(f, guess);
}
/// <summary>Expand a bracket outward from the guess until f changes sign,
/// then bisect. Covers rate-style roots in (-1, ∞).</summary>
private static double? BisectFallback(Func<double, double> f, double guess)
{
// Rates live in (-1, ∞); clamp the lower probe just above -1 so
// (1+rate)^n stays defined.
double lo = -0.999999, hi = Math.Max(1.0, guess + 1.0);
double flo = f(lo);
if (double.IsNaN(flo) || double.IsInfinity(flo)) return null;
double fhi = f(hi);
int expand = 0;
while ((double.IsNaN(fhi) || double.IsInfinity(fhi) || Math.Sign(flo) == Math.Sign(fhi)) && expand < 200)
{
hi += Math.Max(1.0, Math.Abs(hi)); // grow the upper bound
fhi = f(hi);
expand++;
}
if (double.IsNaN(fhi) || double.IsInfinity(fhi) || Math.Sign(flo) == Math.Sign(fhi))
return null; // no sign change found — genuinely no root in range
for (int i = 0; i < SolverMaxIter * 2; i++)
{
double mid = (lo + hi) / 2;
double fmid = f(mid);
if (double.IsNaN(fmid) || double.IsInfinity(fmid)) return null;
if (Math.Abs(fmid) < SolverTol || (hi - lo) / 2 < SolverTol) return mid;
if (Math.Sign(fmid) == Math.Sign(flo)) { lo = mid; flo = fmid; }
else { hi = mid; }
}
return (lo + hi) / 2;
}
}
@@ -0,0 +1,234 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Special functions ====================
//
// Shared numerical core for the statistical-distribution family: the log-
// gamma function, the regularized incomplete gamma integrals and their
// inverse, the error function, and the standard-normal CDF / inverse. The
// distribution wrappers (NORM.DIST, GAMMA.DIST, CHISQ.DIST, POISSON.DIST, …)
// are thin calls onto these. Algorithms are the standard Lanczos
// approximation and the series / continued-fraction expansions for the
// incomplete gamma integral (convergence to ~1e-12), which is what keeps the
// predicted value within Excel's cache-staleness tolerance.
private const double Sqrt2 = 1.4142135623730951;
private const double Sqrt2Pi = 2.5066282746310002;
// Lanczos log-gamma (g=7, n=9). Accurate to ~1e-15 for x > 0.
private static readonly double[] LanczosG7 =
{
0.99999999999980993, 676.5203681218851, -1259.1392167224028,
771.32342877765313, -176.61502916214059, 12.507343278686905,
-0.13857109526572012, 9.9843695780195716e-6, 1.5056327351493116e-7,
};
internal static double GammaLn(double x)
{
if (x <= 0) return double.NaN;
if (x < 0.5)
// reflection: Γ(x)Γ(1-x) = π / sin(πx)
return Math.Log(Math.PI / Math.Sin(Math.PI * x)) - GammaLn(1 - x);
x -= 1;
double a = LanczosG7[0];
double t = x + 7.5;
for (int i = 1; i < LanczosG7.Length; i++) a += LanczosG7[i] / (x + i);
return 0.5 * Math.Log(2 * Math.PI) + (x + 0.5) * Math.Log(t) - t + Math.Log(a);
}
internal static double Gamma(double x)
{
if (x <= 0 && x == Math.Floor(x)) return double.NaN; // poles at 0, -1, -2…
if (x < 0.5) return Math.PI / (Math.Sin(Math.PI * x) * Gamma(1 - x));
return Math.Exp(GammaLn(x));
}
// Lower regularized incomplete gamma P(a,x) = γ(a,x)/Γ(a). Q = 1 - P.
internal static double RegGammaP(double a, double x)
{
if (x < 0 || a <= 0) return double.NaN;
if (x == 0) return 0;
if (x < a + 1) return GammaSeries(a, x); // series converges fast here
return 1.0 - GammaContinuedFraction(a, x); // CF for the upper tail
}
internal static double RegGammaQ(double a, double x)
{
if (x < 0 || a <= 0) return double.NaN;
if (x == 0) return 1;
if (x < a + 1) return 1.0 - GammaSeries(a, x);
return GammaContinuedFraction(a, x);
}
private static double GammaSeries(double a, double x)
{
double ap = a, sum = 1.0 / a, del = sum;
for (int n = 0; n < 200; n++)
{
ap += 1; del *= x / ap; sum += del;
if (Math.Abs(del) < Math.Abs(sum) * 1e-15) break;
}
return sum * Math.Exp(-x + a * Math.Log(x) - GammaLn(a));
}
private static double GammaContinuedFraction(double a, double x)
{
const double tiny = 1e-300;
double b = x + 1 - a, c = 1 / tiny, d = 1 / b, h = d;
for (int i = 1; i < 200; i++)
{
double an = -i * (i - a);
b += 2;
d = an * d + b; if (Math.Abs(d) < tiny) d = tiny;
c = b + an / c; if (Math.Abs(c) < tiny) c = tiny;
d = 1 / d;
double del = d * c;
h *= del;
if (Math.Abs(del - 1) < 1e-15) break;
}
return Math.Exp(-x + a * Math.Log(x) - GammaLn(a)) * h;
}
// Inverse of P(a,x)=p in x. Newton on P with the gamma PDF as derivative,
// seeded by a WilsonHilferty / tail estimate.
internal static double InvRegGammaP(double a, double p)
{
if (p <= 0) return 0;
if (p >= 1) return double.PositiveInfinity;
double gln = GammaLn(a);
double x;
// initial guess
if (a > 1)
{
double pp = p < 0.5 ? p : 1 - p;
double tt = Math.Sqrt(-2 * Math.Log(pp));
double xx = (2.30753 + tt * 0.27061) / (1 + tt * (0.99229 + tt * 0.04481)) - tt;
if (p < 0.5) xx = -xx;
x = Math.Max(1e-3, a * Math.Pow(1 - 1.0 / (9 * a) + xx / (3 * Math.Sqrt(a)), 3));
}
else
{
double t = 1 - a * (0.253 + a * 0.12);
x = p < t ? Math.Pow(p / t, 1 / a) : 1 - Math.Log(1 - (p - t) / (1 - t));
}
for (int i = 0; i < 100; i++)
{
double err = RegGammaP(a, x) - p;
double pdf = Math.Exp(-x + (a - 1) * Math.Log(x) - gln); // d/dx P(a,x)
double dx = err / (pdf > 1e-300 ? pdf : 1e-300);
// damp so we never step below zero
x -= (Math.Abs(dx) < 0.5 * x ? dx : 0.5 * x * Math.Sign(dx));
if (x <= 0) x = 1e-12;
if (Math.Abs(dx) < 1e-12 * x) break;
}
return x;
}
// erf / erfc via the incomplete gamma relation (high precision):
// erf(x) = P(1/2, x²) for x ≥ 0.
internal static double Erf(double x) => x < 0 ? -RegGammaP(0.5, x * x) : RegGammaP(0.5, x * x);
internal static double Erfc(double x) => 1.0 - Erf(x);
internal static double NormPdf(double z) => Math.Exp(-0.5 * z * z) / Sqrt2Pi;
internal static double NormCdf(double z) => 0.5 * Erfc(-z / Sqrt2);
// Inverse standard-normal CDF (Acklam's rational approximation, then one
// Halley refinement for full double precision).
internal static double InvNormCdf(double p)
{
if (p <= 0) return double.NegativeInfinity;
if (p >= 1) return double.PositiveInfinity;
double[] a = { -3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02, 1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00 };
double[] b = { -5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02, 6.680131188771972e+01, -1.328068155288572e+01 };
double[] c = { -7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00, -2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00 };
double[] d = { 7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00, 3.754408661907416e+00 };
const double plow = 0.02425, phigh = 1 - 0.02425;
double q, r, z;
if (p < plow)
{
q = Math.Sqrt(-2 * Math.Log(p));
z = (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
}
else if (p <= phigh)
{
q = p - 0.5; r = q * q;
z = (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q /
(((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1);
}
else
{
q = Math.Sqrt(-2 * Math.Log(1 - p));
z = -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) /
((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1);
}
// Halley step
double e = NormCdf(z) - p;
double u = e * Sqrt2Pi * Math.Exp(0.5 * z * z);
z -= u / (1 + 0.5 * z * u);
return z;
}
// Regularized incomplete beta I_x(a,b) and its inverse — base for the
// T / F / BETA / BINOM distributions (added in a later wave).
internal static double RegIncBeta(double x, double a, double b)
{
if (x <= 0) return 0;
if (x >= 1) return 1;
double lbeta = GammaLn(a) + GammaLn(b) - GammaLn(a + b);
double front = Math.Exp(Math.Log(x) * a + Math.Log(1 - x) * b - lbeta) / a;
// continued fraction (Lentz), use symmetry for fast convergence
if (x < (a + 1) / (a + b + 2))
return front * BetaCF(x, a, b);
return 1 - Math.Exp(Math.Log(1 - x) * b + Math.Log(x) * a - lbeta) / b * BetaCF(1 - x, b, a);
}
// Inverse of I_x(a,b)=p in x, by bisection (monotonic in x on [0,1]).
internal static double InvRegIncBeta(double p, double a, double b)
{
if (p <= 0) return 0;
if (p >= 1) return 1;
double lo = 0, hi = 1;
for (int i = 0; i < 200; i++)
{
double mid = 0.5 * (lo + hi);
if (RegIncBeta(mid, a, b) < p) lo = mid; else hi = mid;
if (hi - lo < 1e-15) break;
}
return 0.5 * (lo + hi);
}
// Binomial coefficient C(n,k) via log-gamma (stable for large n).
internal static double Binom(double n, double k)
{
if (k < 0 || k > n) return 0;
return Math.Round(Math.Exp(GammaLn(n + 1) - GammaLn(k + 1) - GammaLn(n - k + 1)));
}
private static double BetaCF(double x, double a, double b)
{
const double tiny = 1e-300;
double qab = a + b, qap = a + 1, qam = a - 1;
double c = 1, d = 1 - qab * x / qap;
if (Math.Abs(d) < tiny) d = tiny;
d = 1 / d; double h = d;
for (int m = 1; m < 300; m++)
{
int m2 = 2 * m;
double aa = m * (b - m) * x / ((qam + m2) * (a + m2));
d = 1 + aa * d; if (Math.Abs(d) < tiny) d = tiny;
c = 1 + aa / c; if (Math.Abs(c) < tiny) c = tiny;
d = 1 / d; h *= d * c;
aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2));
d = 1 + aa * d; if (Math.Abs(d) < tiny) d = tiny;
c = 1 + aa / c; if (Math.Abs(c) < tiny) c = tiny;
d = 1 / d; double del = d * c; h *= del;
if (Math.Abs(del - 1) < 1e-15) break;
}
return h;
}
}
@@ -0,0 +1,507 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
namespace OfficeCli.Core;
// W6 — dynamic-array (spill) functions. Each returns a FormulaResult.Area
// wrapping a 2D RangeData; the evaluator's top-level collapse (EvaluateFormula)
// reports the anchor (top-left) cell as the cell's computedValue, while the
// OOXML writeback (ExcelHandler.DynamicArray) flags the anchor so Excel 365
// recomputes and spills the full region itself. We never materialize the
// spilled "ghost" cells to disk — Excel owns the spill region (path A).
//
// Verification against real Excel reads the anchor directly and probes interior
// cells by wrapping the spill in a scalar reducer (INDEX/SUM/COUNT), since the
// ghost cells are not written.
internal partial class FormulaEvaluator
{
// ---- shared shape helpers ----
// Any arg (range, area, array-literal, scalar, double[]) → a dense 2D grid.
private static FormulaResult?[,]? ToGrid(object? a)
{
if (AsRangeData(a) is { } rd) return rd.Cells;
if (a is FormulaResult fr)
{
if (fr.IsArray)
{
var arr = fr.ArrayValue!;
var g = new FormulaResult?[1, arr.Length];
for (int i = 0; i < arr.Length; i++) g[0, i] = FormulaResult.Number(arr[i]);
return g;
}
return new FormulaResult?[1, 1] { { fr } };
}
if (a is double[] d)
{
var g = new FormulaResult?[1, d.Length];
for (int i = 0; i < d.Length; i++) g[0, i] = FormulaResult.Number(d[i]);
return g;
}
return null;
}
private static FormulaResult MakeArea(FormulaResult?[,] cells) => FormulaResult.Area(new RangeData(cells));
private static FormulaResult Cell0(FormulaResult? c) => c ?? FormulaResult.Number(0);
// Numeric scalar from arg i (Excel coerces; default when omitted/blank).
private static double Scalar(List<object> args, int i, double def)
=> i < args.Count && args[i] is FormulaResult r && !r.IsBlank ? r.AsNumber() : def;
private static bool ScalarBool(List<object> args, int i, bool def)
{
if (i >= args.Count || args[i] is not FormulaResult r || r.IsBlank) return def;
return r.IsBool ? r.BoolValue!.Value : r.AsNumber() != 0;
}
// Optional integer arg: null when absent, blank, or not a scalar.
private static int? OptInt(List<object> args, int i)
=> i < args.Count && args[i] is FormulaResult r && !r.IsBlank ? (int)r.AsNumber() : (int?)null;
// Flatten a grid to a row-major bool vector (for FILTER's include mask).
private static bool[] TruthVector(FormulaResult?[,] g)
{
int rows = g.GetLength(0), cols = g.GetLength(1);
var v = new bool[rows * cols];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
v[r * cols + c] = (g[r, c]?.AsNumber() ?? 0) != 0;
return v;
}
// ---- generators / reshapers ----
// SEQUENCE(rows, [cols], [start], [step]) — row-major arithmetic fill.
private FormulaResult? EvalSequence(List<object> args)
{
if (args.Count < 1) return FormulaResult.Error("#VALUE!");
int rows = (int)Scalar(args, 0, 0);
int cols = (int)Scalar(args, 1, 1);
double start = Scalar(args, 2, 1), step = Scalar(args, 3, 1);
if (rows < 1 || cols < 1) return FormulaResult.Error("#VALUE!");
if ((long)rows * cols > 1_048_576) return FormulaResult.Error("#NUM!");
var cells = new FormulaResult?[rows, cols];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
cells[r, c] = FormulaResult.Number(start + (r * cols + c) * step);
return MakeArea(cells);
}
// TRANSPOSE(array) — swap rows/cols.
private FormulaResult? EvalTranspose(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0), cols = g.GetLength(1);
var outc = new FormulaResult?[cols, rows];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
outc[c, r] = g[r, c];
return MakeArea(outc);
}
// SORT(array, [sort_index], [sort_order], [by_col]).
private FormulaResult? EvalSort(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int idx = (int)Scalar(args, 1, 1);
int order = (int)Scalar(args, 2, 1);
bool byCol = ScalarBool(args, 3, false);
return SortGrid(g, idx, order, byCol, keys: null);
}
// SORTBY(array, by_array1, [order1], by_array2, [order2], …).
private FormulaResult? EvalSortBy(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0);
var keyCols = new List<(FormulaResult?[] key, int order)>();
for (int i = 1; i < args.Count; i += 2)
{
if (ToGrid(args[i]) is not { } kg) break;
var flat = Flatten(kg);
if (flat.Length != rows) return FormulaResult.Error("#VALUE!");
int ord = (int)Scalar(args, i + 1, 1);
keyCols.Add((flat, ord));
}
if (keyCols.Count == 0) return MakeArea(g);
var rowIdx = Enumerable.Range(0, rows).ToList();
rowIdx.Sort((a, b) =>
{
foreach (var (key, ord) in keyCols)
{
int cmp = CompareCells(key[a], key[b]) * (ord < 0 ? -1 : 1);
if (cmp != 0) return cmp;
}
return 0;
});
return MakeArea(PickRows(g, rowIdx));
}
// Sort rows (or columns when byCol) by the idx-th key line.
private FormulaResult SortGrid(FormulaResult?[,] g, int idx, int order, bool byCol, FormulaResult?[]? keys)
{
int rows = g.GetLength(0), cols = g.GetLength(1);
int sign = order < 0 ? -1 : 1;
if (byCol)
{
if (idx < 1 || idx > rows) return FormulaResult.Error("#VALUE!");
var colIdx = Enumerable.Range(0, cols).ToList();
colIdx.Sort((a, b) => CompareCells(g[idx - 1, a], g[idx - 1, b]) * sign);
return MakeArea(PickCols(g, colIdx));
}
if (idx < 1 || idx > cols) return FormulaResult.Error("#VALUE!");
var rowIdx = Enumerable.Range(0, rows).ToList();
rowIdx.Sort((a, b) => CompareCells(g[a, idx - 1], g[b, idx - 1]) * sign);
return MakeArea(PickRows(g, rowIdx));
}
// UNIQUE(array, [by_col], [exactly_once]).
private FormulaResult? EvalUnique(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
bool byCol = ScalarBool(args, 1, false);
bool exactlyOnce = ScalarBool(args, 2, false);
int rows = g.GetLength(0), cols = g.GetLength(1);
if (byCol)
{
var seen = new List<(string key, int idx)>();
var counts = new Dictionary<string, int>();
var keyByCol = new string[cols];
for (int c = 0; c < cols; c++)
{
var key = ColKey(g, c);
keyByCol[c] = key;
counts[key] = counts.GetValueOrDefault(key) + 1;
}
var kept = new List<int>(); var added = new HashSet<string>();
for (int c = 0; c < cols; c++)
{
var key = keyByCol[c];
bool eligible = exactlyOnce ? counts[key] == 1 : added.Add(key);
if (exactlyOnce ? counts[key] == 1 : eligible) kept.Add(c);
}
return kept.Count == 0 ? FormulaResult.Error("#CALC!") : MakeArea(PickCols(g, kept));
}
else
{
var counts = new Dictionary<string, int>();
var keyByRow = new string[rows];
for (int r = 0; r < rows; r++)
{
var key = RowKey(g, r);
keyByRow[r] = key;
counts[key] = counts.GetValueOrDefault(key) + 1;
}
var kept = new List<int>(); var added = new HashSet<string>();
for (int r = 0; r < rows; r++)
{
var key = keyByRow[r];
if (exactlyOnce ? counts[key] == 1 : added.Add(key)) kept.Add(r);
}
return kept.Count == 0 ? FormulaResult.Error("#CALC!") : MakeArea(PickRows(g, kept));
}
}
// FILTER(array, include, [if_empty]).
private FormulaResult? EvalFilter(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
if (ToGrid(args.Count > 1 ? args[1] : null) is not { } inc) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0), cols = g.GetLength(1);
int incRows = inc.GetLength(0), incCols = inc.GetLength(1);
var mask = TruthVector(inc);
FormulaResult? ifEmpty = args.Count > 2 && args[2] is FormulaResult fe ? fe : null;
// include shape picks the axis: a column vector filters rows, a row
// vector filters columns. Fall back to length-matching when the shape
// is ambiguous (e.g. a 1×N mask over an N×N array).
bool filterRows = (incCols == 1 && incRows == rows) || (incRows != 1 && mask.Length == rows) || mask.Length == rows;
if (filterRows && mask.Length == rows)
{
var keep = Enumerable.Range(0, rows).Where(r => mask[r]).ToList();
return keep.Count == 0 ? (ifEmpty ?? FormulaResult.Error("#CALC!")) : MakeArea(PickRows(g, keep));
}
if (mask.Length == cols)
{
var keep = Enumerable.Range(0, cols).Where(c => mask[c]).ToList();
return keep.Count == 0 ? (ifEmpty ?? FormulaResult.Error("#CALC!")) : MakeArea(PickCols(g, keep));
}
return FormulaResult.Error("#VALUE!");
}
// TAKE(array, rows, [cols]) — keep first |rows|/|cols| (negative = from end).
private FormulaResult? EvalTake(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0), cols = g.GetLength(1);
var rIdx = SliceIndices(rows, OptInt(args, 1), take: true);
var cIdx = SliceIndices(cols, OptInt(args, 2), take: true);
return MakeArea(PickRC(g, rIdx, cIdx));
}
// DROP(array, rows, [cols]) — drop first |rows|/|cols| (negative = from end).
private FormulaResult? EvalDrop(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0), cols = g.GetLength(1);
var rIdx = SliceIndices(rows, OptInt(args, 1), take: false);
var cIdx = SliceIndices(cols, OptInt(args, 2), take: false);
if (rIdx.Count == 0 || cIdx.Count == 0) return FormulaResult.Error("#CALC!");
return MakeArea(PickRC(g, rIdx, cIdx));
}
// CHOOSEROWS(array, n1, n2, …) / CHOOSECOLS — pick by 1-based index (neg from end).
private FormulaResult? EvalChooseRowsCols(List<object> args, bool rowsMode)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int n = rowsMode ? g.GetLength(0) : g.GetLength(1);
var idx = new List<int>();
for (int i = 1; i < args.Count; i++)
{
if (ToGrid(args[i]) is not { } sel) continue;
foreach (var v in Flatten(sel))
{
int k = (int)(v?.AsNumber() ?? 0);
if (k < 0) k = n + k + 1;
if (k < 1 || k > n) return FormulaResult.Error("#VALUE!");
idx.Add(k - 1);
}
}
if (idx.Count == 0) return FormulaResult.Error("#VALUE!");
return MakeArea(rowsMode ? PickRows(g, idx) : PickCols(g, idx));
}
// TOCOL / TOROW(array, [ignore], [scan_by_col]).
private FormulaResult? EvalToColRow(List<object> args, bool toCol)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int ignore = (int)Scalar(args, 1, 0);
bool scanByCol = ScalarBool(args, 2, false);
int rows = g.GetLength(0), cols = g.GetLength(1);
var flat = new List<FormulaResult?>();
if (scanByCol)
for (int c = 0; c < cols; c++) for (int r = 0; r < rows; r++) AddIfKept(g[r, c]);
else
for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) AddIfKept(g[r, c]);
void AddIfKept(FormulaResult? cell)
{
bool blank = cell == null || cell.IsBlank || (cell.IsString && cell.StringValue == "");
bool err = cell?.IsError == true;
if ((ignore is 1 or 3) && blank) return;
if ((ignore is 2 or 3) && err) return;
flat.Add(cell);
}
if (flat.Count == 0) return FormulaResult.Error("#CALC!");
FormulaResult?[,] outc = toCol ? new FormulaResult?[flat.Count, 1] : new FormulaResult?[1, flat.Count];
for (int i = 0; i < flat.Count; i++) { if (toCol) outc[i, 0] = flat[i]; else outc[0, i] = flat[i]; }
return MakeArea(outc);
}
// EXPAND(array, rows, [cols], [pad_with]) — pad to rows×cols (default #N/A).
private FormulaResult? EvalExpand(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int curR = g.GetLength(0), curC = g.GetLength(1);
int nr = OptInt(args, 1) ?? curR;
int nc = OptInt(args, 2) ?? curC;
if (nr < curR || nc < curC) return FormulaResult.Error("#VALUE!");
FormulaResult? pad = args.Count > 3 && args[3] is FormulaResult pf ? pf : FormulaResult.Error("#N/A");
var outc = new FormulaResult?[nr, nc];
for (int r = 0; r < nr; r++)
for (int c = 0; c < nc; c++)
outc[r, c] = r < curR && c < curC ? g[r, c] : pad;
return MakeArea(outc);
}
// HSTACK / VSTACK(a, b, …) — concat, padding the short dimension with #N/A.
private FormulaResult? EvalStack(List<object> args, bool horizontal)
{
var grids = new List<FormulaResult?[,]>();
foreach (var a in args) if (ToGrid(a) is { } g) grids.Add(g);
if (grids.Count == 0) return FormulaResult.Error("#VALUE!");
var na = FormulaResult.Error("#N/A");
if (horizontal)
{
int rows = grids.Max(g => g.GetLength(0));
int cols = grids.Sum(g => g.GetLength(1));
var outc = new FormulaResult?[rows, cols];
int co = 0;
foreach (var g in grids)
{
int gr = g.GetLength(0), gc = g.GetLength(1);
for (int r = 0; r < rows; r++)
for (int c = 0; c < gc; c++)
outc[r, co + c] = r < gr ? g[r, c] : na;
co += gc;
}
return MakeArea(outc);
}
else
{
int cols = grids.Max(g => g.GetLength(1));
int rows = grids.Sum(g => g.GetLength(0));
var outc = new FormulaResult?[rows, cols];
int ro = 0;
foreach (var g in grids)
{
int gr = g.GetLength(0), gc = g.GetLength(1);
for (int r = 0; r < gr; r++)
for (int c = 0; c < cols; c++)
outc[ro + r, c] = c < gc ? g[r, c] : na;
ro += gr;
}
return MakeArea(outc);
}
}
// WRAPROWS / WRAPCOLS(vector, wrap_count, [pad_with]).
private FormulaResult? EvalWrap(List<object> args, bool byRows)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g) return FormulaResult.Error("#VALUE!");
int wrap = (int)Scalar(args, 1, 0);
if (wrap < 1) return FormulaResult.Error("#NUM!");
FormulaResult? pad = args.Count > 2 && args[2] is FormulaResult pf ? pf : FormulaResult.Error("#N/A");
var flat = Flatten(g);
int n = flat.Length;
if (byRows)
{
int rows = (n + wrap - 1) / wrap;
var outc = new FormulaResult?[rows, wrap];
for (int i = 0; i < rows * wrap; i++)
outc[i / wrap, i % wrap] = i < n ? flat[i] : pad;
return MakeArea(outc);
}
else
{
int cols = (n + wrap - 1) / wrap;
var outc = new FormulaResult?[wrap, cols];
for (int i = 0; i < cols * wrap; i++)
outc[i % wrap, i / wrap] = i < n ? flat[i] : pad;
return MakeArea(outc);
}
}
// TEXTSPLIT(text, col_delim, [row_delim], [ignore_empty], [match_mode], [pad_with]).
private FormulaResult? EvalTextSplit(List<object> args)
{
string text = args.Count > 0 && args[0] is FormulaResult t ? t.AsString() : "";
var colDelims = DelimList(args, 1);
var rowDelims = DelimList(args, 2);
bool ignoreEmpty = ScalarBool(args, 3, false);
FormulaResult? pad = args.Count > 5 && args[5] is FormulaResult pf ? pf : FormulaResult.Error("#N/A");
string[] rowParts = rowDelims.Count > 0
? SplitOnAny(text, rowDelims, ignoreEmpty)
: new[] { text };
var rows = rowParts.Select(rp => colDelims.Count > 0
? SplitOnAny(rp, colDelims, ignoreEmpty)
: new[] { rp }).ToList();
int cols = rows.Max(r => r.Length);
var outc = new FormulaResult?[rows.Count, cols];
for (int r = 0; r < rows.Count; r++)
for (int c = 0; c < cols; c++)
outc[r, c] = c < rows[r].Length ? FormulaResult.Str(rows[r][c]) : pad;
return MakeArea(outc);
}
// ---- low-level grid utilities ----
private static List<string> DelimList(List<object> args, int i)
{
var list = new List<string>();
if (i < args.Count && ToGrid(args[i]) is { } g)
foreach (var c in Flatten(g))
{
var s = c?.AsString();
if (!string.IsNullOrEmpty(s)) list.Add(s);
}
return list;
}
private static string[] SplitOnAny(string s, List<string> delims, bool ignoreEmpty)
{
var parts = s.Split(delims.ToArray(), ignoreEmpty ? StringSplitOptions.RemoveEmptyEntries : StringSplitOptions.None);
return parts.Length == 0 ? new[] { "" } : parts;
}
private static FormulaResult?[] Flatten(FormulaResult?[,] g)
{
int rows = g.GetLength(0), cols = g.GetLength(1);
var f = new FormulaResult?[rows * cols];
for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) f[r * cols + c] = g[r, c];
return f;
}
private static FormulaResult?[,] PickRows(FormulaResult?[,] g, IReadOnlyList<int> rowIdx)
{
int cols = g.GetLength(1);
var outc = new FormulaResult?[rowIdx.Count, cols];
for (int i = 0; i < rowIdx.Count; i++)
for (int c = 0; c < cols; c++) outc[i, c] = g[rowIdx[i], c];
return outc;
}
private static FormulaResult?[,] PickCols(FormulaResult?[,] g, IReadOnlyList<int> colIdx)
{
int rows = g.GetLength(0);
var outc = new FormulaResult?[rows, colIdx.Count];
for (int r = 0; r < rows; r++)
for (int i = 0; i < colIdx.Count; i++) outc[r, i] = g[r, colIdx[i]];
return outc;
}
private static FormulaResult?[,] PickRC(FormulaResult?[,] g, IReadOnlyList<int> rowIdx, IReadOnlyList<int> colIdx)
{
var outc = new FormulaResult?[rowIdx.Count, colIdx.Count];
for (int i = 0; i < rowIdx.Count; i++)
for (int j = 0; j < colIdx.Count; j++) outc[i, j] = g[rowIdx[i], colIdx[j]];
return outc;
}
// TAKE/DROP slice: n total, count>0 from start, count<0 from end. null = all.
private static List<int> SliceIndices(int n, int? count, bool take)
{
if (count is null) return Enumerable.Range(0, n).ToList();
int k = count.Value;
if (take)
{
int m = Math.Min(Math.Abs(k), n);
return k >= 0 ? Enumerable.Range(0, m).ToList() : Enumerable.Range(n - m, m).ToList();
}
// drop
int d = Math.Min(Math.Abs(k), n);
return k >= 0 ? Enumerable.Range(d, n - d).ToList() : Enumerable.Range(0, n - d).ToList();
}
private static string RowKey(FormulaResult?[,] g, int r)
{
int cols = g.GetLength(1);
return string.Join("", Enumerable.Range(0, cols).Select(c => CellKey(g[r, c])));
}
private static string ColKey(FormulaResult?[,] g, int c)
{
int rows = g.GetLength(0);
return string.Join("", Enumerable.Range(0, rows).Select(r => CellKey(g[r, c])));
}
private static string CellKey(FormulaResult? c)
{
if (c == null || c.IsBlank) return "";
if (c.IsNumeric) return "n:" + c.NumericValue!.Value.ToString("R", CultureInfo.InvariantCulture);
if (c.IsBool) return "b:" + (c.BoolValue!.Value ? 1 : 0);
if (c.IsError) return "e:" + c.ErrorValue;
return "s:" + c.AsString().ToUpperInvariant();
}
private static int CompareCells(FormulaResult? a, FormulaResult? b)
{
a ??= FormulaResult.Number(0); b ??= FormulaResult.Number(0);
return CompareValues(a, b);
}
}
@@ -0,0 +1,111 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
// W6b — the lambda-driven spill functions (MAP / BYROW / BYCOL / SCAN /
// MAKEARRAY). They build on the W5 LAMBDA machinery: the trailing argument
// arrives as an IsLambda FormulaResult and is applied per element/row/col/cell
// via InvokeLambda. Like the rest of W6 they return a FormulaResult.Area whose
// top-left becomes the anchor's computedValue while Excel spills the region.
internal partial class FormulaEvaluator
{
private static Lambda? AsLambda(object? a)
=> a is FormulaResult { IsLambda: true } fr ? (Lambda)fr.LambdaValue! : null;
// A single grid row / column as a 1×N / N×1 Area (so a lambda body like
// SUM(r) sees a real range).
private static FormulaResult RowArea(FormulaResult?[,] g, int r)
{
int cols = g.GetLength(1);
var row = new FormulaResult?[1, cols];
for (int c = 0; c < cols; c++) row[0, c] = g[r, c];
return MakeArea(row);
}
private static FormulaResult ColArea(FormulaResult?[,] g, int c)
{
int rows = g.GetLength(0);
var col = new FormulaResult?[rows, 1];
for (int r = 0; r < rows; r++) col[r, 0] = g[r, c];
return MakeArea(col);
}
// MAP(array1, [array2, …], lambda) — apply lambda elementwise across one or
// more equally-shaped arrays.
private FormulaResult? EvalMap(List<object> args)
{
if (args.Count < 2 || AsLambda(args[^1]) is not { } lam) return FormulaResult.Error("#VALUE!");
var grids = new List<FormulaResult?[,]>();
for (int i = 0; i < args.Count - 1; i++)
{
if (ToGrid(args[i]) is not { } g) return FormulaResult.Error("#VALUE!");
grids.Add(g);
}
int rows = grids[0].GetLength(0), cols = grids[0].GetLength(1);
if (grids.Any(g => g.GetLength(0) != rows || g.GetLength(1) != cols))
return FormulaResult.Error("#VALUE!");
var outc = new FormulaResult?[rows, cols];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
outc[r, c] = InvokeLambda(lam, grids.Select(g => Cell0(g[r, c])).ToList());
return MakeArea(outc);
}
// BYROW(array, lambda) — lambda(row) → scalar; result is a column vector.
private FormulaResult? EvalByRow(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g || AsLambda(args.Count > 1 ? args[1] : null) is not { } lam)
return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0);
var outc = new FormulaResult?[rows, 1];
for (int r = 0; r < rows; r++)
outc[r, 0] = InvokeLambda(lam, new List<FormulaResult> { RowArea(g, r) });
return MakeArea(outc);
}
// BYCOL(array, lambda) — lambda(col) → scalar; result is a row vector.
private FormulaResult? EvalByCol(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } g || AsLambda(args.Count > 1 ? args[1] : null) is not { } lam)
return FormulaResult.Error("#VALUE!");
int cols = g.GetLength(1);
var outc = new FormulaResult?[1, cols];
for (int c = 0; c < cols; c++)
outc[0, c] = InvokeLambda(lam, new List<FormulaResult> { ColArea(g, c) });
return MakeArea(outc);
}
// SCAN(init, array, lambda) — running fold; result mirrors array's shape with
// each cell holding the accumulation up to and including that element.
private FormulaResult? EvalScan(List<object> args)
{
if (args.Count < 3) return FormulaResult.Error("#VALUE!");
var acc = args[0] as FormulaResult ?? FormulaResult.Number(0);
if (ToGrid(args[1]) is not { } g || AsLambda(args[2]) is not { } lam) return FormulaResult.Error("#VALUE!");
int rows = g.GetLength(0), cols = g.GetLength(1);
var outc = new FormulaResult?[rows, cols];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
{
acc = InvokeLambda(lam, new List<FormulaResult> { acc, Cell0(g[r, c]) });
outc[r, c] = acc;
}
return MakeArea(outc);
}
// MAKEARRAY(rows, cols, lambda) — lambda(r, c) over 1-based indices.
private FormulaResult? EvalMakeArray(List<object> args)
{
if (args.Count < 3 || AsLambda(args[2]) is not { } lam) return FormulaResult.Error("#VALUE!");
int rows = (int)Scalar(args, 0, 0), cols = (int)Scalar(args, 1, 0);
if (rows < 1 || cols < 1) return FormulaResult.Error("#VALUE!");
if ((long)rows * cols > 1_048_576) return FormulaResult.Error("#NUM!");
var outc = new FormulaResult?[rows, cols];
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
outc[r, c] = InvokeLambda(lam,
new List<FormulaResult> { FormulaResult.Number(r + 1), FormulaResult.Number(c + 1) });
return MakeArea(outc);
}
}
@@ -0,0 +1,298 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Statistical distribution wrappers ====================
//
// Thin wrappers over the special-function core (FormulaEvaluator.Special-
// Functions.cs). Each takes the same arguments Excel does; the `cumulative`
// flag selects CDF vs PDF. These are the normal- / gamma- / chi-squared- /
// Poisson- / exponential-family functions whose engines are erf and the
// regularized incomplete gamma integral.
private static double N(List<object> a, int i, double def = 0) =>
i < a.Count && a[i] is FormulaResult r ? r.AsNumber() : def;
private static bool Cumulative(List<object> a, int i) =>
i < a.Count && a[i] is FormulaResult r && r.AsNumber() != 0;
// NORM.DIST(x, mean, sd, cumulative) / NORMDIST.
private static FormulaResult? EvalNormDist(List<object> args)
{
if (args.Count < 4) return null;
double x = N(args, 0), mean = N(args, 1), sd = N(args, 2);
if (sd <= 0) return FormulaResult.Error("#NUM!");
double z = (x - mean) / sd;
return FR(Cumulative(args, 3) ? NormCdf(z) : NormPdf(z) / sd);
}
// NORM.S.DIST(z, cumulative).
private static FormulaResult? EvalNormSDist(List<object> args)
{
if (args.Count < 1) return null;
double z = N(args, 0);
return FR(Cumulative(args, 1) ? NormCdf(z) : NormPdf(z));
}
// CONFIDENCE.NORM(alpha, sd, size) — half-width of the normal CI.
private static FormulaResult? EvalConfidenceNorm(List<object> args)
{
if (args.Count < 3) return null;
double alpha = N(args, 0), sd = N(args, 1), size = N(args, 2);
if (alpha <= 0 || alpha >= 1 || sd <= 0 || size < 1) return FormulaResult.Error("#NUM!");
return FR(InvNormCdf(1 - alpha / 2) * sd / Math.Sqrt(size));
}
// ERF(lower, [upper]) — error function, optionally over an interval.
private static FormulaResult? EvalErf(List<object> args)
{
if (args.Count < 1) return null;
double lower = N(args, 0);
return args.Count >= 2 ? FR(Erf(N(args, 1)) - Erf(lower)) : FR(Erf(lower));
}
// GAMMA(x) — Γ(x); poles at 0 and the negative integers.
private static FormulaResult? EvalGamma(List<object> args)
{
if (args.Count < 1) return null;
double x = N(args, 0);
if (x <= 0 && x == Math.Floor(x)) return FormulaResult.Error("#NUM!");
var g = Gamma(x);
return double.IsNaN(g) ? FormulaResult.Error("#NUM!") : FR(g);
}
// GAMMA.DIST(x, alpha, beta, cumulative) / GAMMADIST.
private static FormulaResult? EvalGammaDist(List<object> args)
{
if (args.Count < 4) return null;
double x = N(args, 0), alpha = N(args, 1), beta = N(args, 2);
if (x < 0 || alpha <= 0 || beta <= 0) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 3)) return FR(RegGammaP(alpha, x / beta));
double pdf = Math.Exp((alpha - 1) * Math.Log(x) - x / beta - alpha * Math.Log(beta) - GammaLn(alpha));
return FR(pdf);
}
// CHISQ.DIST(x, df, cumulative) — chi-squared = gamma(df/2, 2).
private static FormulaResult? EvalChisqDist(List<object> args)
{
if (args.Count < 3) return null;
double x = N(args, 0), df = N(args, 1);
if (x < 0 || df < 1) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 2)) return FR(RegGammaP(df / 2, x / 2));
double pdf = Math.Exp((df / 2 - 1) * Math.Log(x) - x / 2 - (df / 2) * Math.Log(2) - GammaLn(df / 2));
return FR(pdf);
}
// POISSON.DIST(x, mean, cumulative) / POISSON.
private static FormulaResult? EvalPoisson(List<object> args)
{
if (args.Count < 3) return null;
double k = Math.Floor(N(args, 0)), mean = N(args, 1);
if (k < 0 || mean < 0) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 2)) return FR(RegGammaQ(k + 1, mean)); // CDF P(X≤k)
return FR(Math.Exp(-mean + k * Math.Log(mean) - GammaLn(k + 1)));
}
// EXPON.DIST(x, lambda, cumulative) / EXPONDIST.
private static FormulaResult? EvalExpon(List<object> args)
{
if (args.Count < 3) return null;
double x = N(args, 0), lambda = N(args, 1);
if (x < 0 || lambda <= 0) return FormulaResult.Error("#NUM!");
return FR(Cumulative(args, 2) ? 1 - Math.Exp(-lambda * x) : lambda * Math.Exp(-lambda * x));
}
// ==================== Incomplete-beta family ====================
// BETA.DIST(x, alpha, beta, cumulative, [A], [B]) / BETADIST (always CDF).
private static FormulaResult? EvalBetaDist(List<object> args)
{
if (args.Count < 3) return null;
double x = N(args, 0), a = N(args, 1), b = N(args, 2);
bool legacy = args.Count < 4 || args[3] is not FormulaResult; // BETADIST has no cumulative flag
bool cum = legacy || Cumulative(args, 3);
double lo = N(args, legacy ? 3 : 4, 0), hi = N(args, legacy ? 4 : 5, 1);
if (a <= 0 || b <= 0 || hi <= lo || x < lo || x > hi) return FormulaResult.Error("#NUM!");
double z = (x - lo) / (hi - lo);
if (cum) return FR(RegIncBeta(z, a, b));
double pdf = Math.Exp((a - 1) * Math.Log(z) + (b - 1) * Math.Log(1 - z)
- (GammaLn(a) + GammaLn(b) - GammaLn(a + b))) / (hi - lo);
return FR(pdf);
}
// BETA.INV(p, alpha, beta, [A], [B]) / BETAINV.
private static FormulaResult? EvalBetaInv(List<object> args)
{
if (args.Count < 3) return null;
double p = N(args, 0), a = N(args, 1), b = N(args, 2), lo = N(args, 3, 0), hi = N(args, 4, 1);
if (a <= 0 || b <= 0 || p < 0 || p > 1 || hi <= lo) return FormulaResult.Error("#NUM!");
return FR(lo + (hi - lo) * InvRegIncBeta(p, a, b));
}
// Student-t CDF and tails via the incomplete beta.
private static double TDistCdf(double t, double df)
{
double x = df / (df + t * t);
double tail = 0.5 * RegIncBeta(x, df / 2, 0.5);
return t >= 0 ? 1 - tail : tail;
}
private static double TDistRightTail(double t, double df) => 1 - TDistCdf(t, df);
// T.DIST(x, df, cumulative).
private static FormulaResult? EvalTDist(List<object> args)
{
if (args.Count < 3) return null;
double t = N(args, 0), df = N(args, 1);
if (df < 1) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 2)) return FR(TDistCdf(t, df));
double pdf = Math.Exp(GammaLn((df + 1) / 2) - GammaLn(df / 2)) / Math.Sqrt(df * Math.PI)
* Math.Pow(1 + t * t / df, -(df + 1) / 2);
return FR(pdf);
}
// T.DIST.2T(x, df) — two-tailed (x ≥ 0).
private static FormulaResult? EvalTDist2T(List<object> args)
{
if (args.Count < 2) return null;
double t = N(args, 0), df = N(args, 1);
if (t < 0 || df < 1) return FormulaResult.Error("#NUM!");
return FR(RegIncBeta(df / (df + t * t), df / 2, 0.5));
}
// TDIST(x, df, tails) — legacy; x ≥ 0, tails ∈ {1,2}.
private static FormulaResult? EvalTDistLegacy(List<object> args)
{
if (args.Count < 3) return null;
double t = N(args, 0), df = N(args, 1); int tails = (int)N(args, 2);
if (t < 0 || df < 1 || (tails != 1 && tails != 2)) return FormulaResult.Error("#NUM!");
double oneTail = TDistRightTail(t, df);
return FR(tails == 1 ? oneTail : 2 * oneTail);
}
private static double TInv(double p, double df)
{
// invert the CDF; CDF is monotonic so bisect over a wide range
double lo = -1e6, hi = 1e6;
for (int i = 0; i < 200; i++)
{
double mid = 0.5 * (lo + hi);
if (TDistCdf(mid, df) < p) lo = mid; else hi = mid;
if (hi - lo < 1e-12) break;
}
return 0.5 * (lo + hi);
}
private static double TInv2T(double p, double df) => TInv(1 - p / 2, df); // two-tailed prob → positive t
// F-distribution CDF via the incomplete beta.
private static double FDistCdf(double x, double d1, double d2)
{
if (x <= 0) return 0;
return RegIncBeta(d1 * x / (d1 * x + d2), d1 / 2, d2 / 2);
}
// F.DIST(x, df1, df2, cumulative).
private static FormulaResult? EvalFDist(List<object> args)
{
if (args.Count < 4) return null;
double x = N(args, 0), d1 = N(args, 1), d2 = N(args, 2);
if (x < 0 || d1 < 1 || d2 < 1) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 3)) return FR(FDistCdf(x, d1, d2));
double pdf = Math.Sqrt(Math.Pow(d1 * x, d1) * Math.Pow(d2, d2) / Math.Pow(d1 * x + d2, d1 + d2))
/ (x * Math.Exp(GammaLn(d1 / 2) + GammaLn(d2 / 2) - GammaLn((d1 + d2) / 2)));
return FR(pdf);
}
private static double FInv(double p, double d1, double d2)
{
double lo = 0, hi = 1e7;
for (int i = 0; i < 200; i++)
{
double mid = 0.5 * (lo + hi);
if (FDistCdf(mid, d1, d2) < p) lo = mid; else hi = mid;
if (hi - lo < 1e-10) break;
}
return 0.5 * (lo + hi);
}
// BINOM.DIST(k, n, p, cumulative) / BINOMDIST.
private static FormulaResult? EvalBinomDist(List<object> args)
{
if (args.Count < 4) return null;
double k = Math.Floor(N(args, 0)), n = Math.Floor(N(args, 1)), p = N(args, 2);
if (k < 0 || k > n || p < 0 || p > 1) return FormulaResult.Error("#NUM!");
if (Cumulative(args, 3))
{
double sum = 0;
for (int i = 0; i <= k; i++) sum += Binom(n, i) * Math.Pow(p, i) * Math.Pow(1 - p, n - i);
return FR(sum);
}
return FR(Binom(n, k) * Math.Pow(p, k) * Math.Pow(1 - p, n - k));
}
// BINOM.INV(n, p, alpha) / CRITBINOM — smallest k with CDF ≥ alpha.
private static FormulaResult? EvalBinomInv(List<object> args)
{
if (args.Count < 3) return null;
double n = Math.Floor(N(args, 0)), p = N(args, 1), alpha = N(args, 2);
if (n < 0 || p < 0 || p > 1 || alpha <= 0 || alpha > 1) return FormulaResult.Error("#NUM!");
double cdf = 0;
for (int k = 0; k <= n; k++)
{
cdf += Binom(n, k) * Math.Pow(p, k) * Math.Pow(1 - p, n - k);
if (cdf >= alpha) return FR(k);
}
return FR(n);
}
// NEGBINOM.DIST(f, s, p, [cumulative]) / NEGBINOMDIST (pmf only).
private static FormulaResult? EvalNegBinom(List<object> args)
{
if (args.Count < 3) return null;
double f = Math.Floor(N(args, 0)), s = Math.Floor(N(args, 1)), p = N(args, 2);
if (f < 0 || s < 1 || p < 0 || p > 1) return FormulaResult.Error("#NUM!");
bool cum = args.Count >= 4 && Cumulative(args, 3);
if (cum)
{
double sum = 0;
for (int i = 0; i <= f; i++) sum += Binom(i + s - 1, i) * Math.Pow(p, s) * Math.Pow(1 - p, i);
return FR(sum);
}
return FR(Binom(f + s - 1, f) * Math.Pow(p, s) * Math.Pow(1 - p, f));
}
// WEIBULL.DIST(x, alpha, beta, cumulative) / WEIBULL.
private static FormulaResult? EvalWeibull(List<object> args)
{
if (args.Count < 4) return null;
double x = N(args, 0), a = N(args, 1), b = N(args, 2);
if (x < 0 || a <= 0 || b <= 0) return FormulaResult.Error("#NUM!");
double z = Math.Pow(x / b, a);
return FR(Cumulative(args, 3) ? 1 - Math.Exp(-z) : a / Math.Pow(b, a) * Math.Pow(x, a - 1) * Math.Exp(-z));
}
// LOGNORM.DIST(x, mean, sd, cumulative) / LOGNORMDIST (always CDF).
private static FormulaResult? EvalLognormDist(List<object> args)
{
if (args.Count < 3) return null;
double x = N(args, 0), mean = N(args, 1), sd = N(args, 2);
if (x <= 0 || sd <= 0) return FormulaResult.Error("#NUM!");
double z = (Math.Log(x) - mean) / sd;
bool cum = args.Count < 4 || args[3] is not FormulaResult || Cumulative(args, 3);
return FR(cum ? NormCdf(z) : NormPdf(z) / (x * sd));
}
// HYPGEOM.DIST(sample_s, sample_n, pop_s, pop_n, [cumulative]) / HYPGEOMDIST.
private static FormulaResult? EvalHypgeom(List<object> args)
{
if (args.Count < 4) return null;
double k = Math.Floor(N(args, 0)), n = Math.Floor(N(args, 1)), K = Math.Floor(N(args, 2)), Npop = Math.Floor(N(args, 3));
if (k < 0 || k > n || n > Npop || K > Npop || k > K || n - k > Npop - K) return FormulaResult.Error("#NUM!");
double Pmf(double i) => Binom(K, i) * Binom(Npop - K, n - i) / Binom(Npop, n);
bool cum = args.Count >= 5 && Cumulative(args, 4);
if (cum) { double sum = 0; for (int i = 0; i <= k; i++) sum += Pmf(i); return FR(sum); }
return FR(Pmf(k));
}
}
@@ -0,0 +1,159 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
internal partial class FormulaEvaluator
{
// ==================== Descriptive & regression statistics ====================
//
// Shape (SKEW/KURT/AVEDEV/DEVSQ/TRIMMEAN) and the pairwise regression family
// (CORREL/COVAR/SLOPE/INTERCEPT/RSQ/STEYX/FORECAST) plus the exclusive
// quartile/percentile variants. Pure array arithmetic — no special functions.
private double[] Flat(List<object> args) => FlattenNumbers(args);
// Pull two aligned numeric arrays (regression takes two ranges).
private static bool Pair(List<object> args, out double[] a, out double[] b)
{
a = AsDoubles(args.Count > 0 ? args[0] : null) ?? [];
b = AsDoubles(args.Count > 1 ? args[1] : null) ?? [];
if (a.Length == 0 || a.Length != b.Length) { a = b = []; return false; }
return true;
}
private FormulaResult? EvalSkew(List<object> args, bool population)
{
var v = Flat(args);
int n = v.Length;
if (population ? n < 1 : n < 3) return FormulaResult.Error("#DIV/0!");
double mean = v.Average();
double sd = Math.Sqrt(v.Sum(x => (x - mean) * (x - mean)) / (population ? n : n - 1));
if (sd == 0) return FormulaResult.Error("#DIV/0!");
double s3 = v.Sum(x => Math.Pow((x - mean) / sd, 3));
return FR(population ? s3 / n : (double)n / ((n - 1.0) * (n - 2.0)) * s3);
}
private FormulaResult? EvalKurt(List<object> args)
{
var v = Flat(args);
int n = v.Length;
if (n < 4) return FormulaResult.Error("#DIV/0!");
double mean = v.Average();
double sd = Math.Sqrt(v.Sum(x => (x - mean) * (x - mean)) / (n - 1.0));
if (sd == 0) return FormulaResult.Error("#DIV/0!");
double s4 = v.Sum(x => Math.Pow((x - mean) / sd, 4));
return FR((double)n * (n + 1) / ((n - 1.0) * (n - 2) * (n - 3)) * s4
- 3.0 * (n - 1) * (n - 1) / ((n - 2.0) * (n - 3)));
}
private FormulaResult? EvalTrimMean(List<object> args)
{
if (args.Count < 2) return null;
var v = AsDoubles(args[0]); double pct = args[1] is FormulaResult r ? r.AsNumber() : 0;
if (v == null || v.Length == 0 || pct < 0 || pct >= 1) return FormulaResult.Error("#NUM!");
var s = v.OrderBy(x => x).ToArray();
int trim = (int)Math.Floor(s.Length * pct / 2); // trimmed from EACH end
var kept = s.Skip(trim).Take(s.Length - 2 * trim).ToArray();
return kept.Length > 0 ? FR(kept.Average()) : FormulaResult.Error("#NUM!");
}
private FormulaResult? EvalCorrel(List<object> args)
{
if (!Pair(args, out var x, out var y)) return FormulaResult.Error("#N/A");
double mx = x.Average(), my = y.Average();
double sxy = 0, sxx = 0, syy = 0;
for (int i = 0; i < x.Length; i++) { double dx = x[i] - mx, dy = y[i] - my; sxy += dx * dy; sxx += dx * dx; syy += dy * dy; }
return sxx == 0 || syy == 0 ? FormulaResult.Error("#DIV/0!") : FR(sxy / Math.Sqrt(sxx * syy));
}
private FormulaResult? EvalCovar(List<object> args, bool sample)
{
if (!Pair(args, out var x, out var y)) return FormulaResult.Error("#N/A");
int n = x.Length;
if (sample && n < 2) return FormulaResult.Error("#DIV/0!");
double mx = x.Average(), my = y.Average(), s = 0;
for (int i = 0; i < n; i++) s += (x[i] - mx) * (y[i] - my);
return FR(s / (sample ? n - 1 : n));
}
// SLOPE(known_y, known_x).
private FormulaResult? EvalSlope(List<object> args)
{
if (!Pair(args, out var y, out var x)) return FormulaResult.Error("#N/A");
double mx = x.Average(), my = y.Average(), sxy = 0, sxx = 0;
for (int i = 0; i < x.Length; i++) { double dx = x[i] - mx; sxy += dx * (y[i] - my); sxx += dx * dx; }
return sxx == 0 ? FormulaResult.Error("#DIV/0!") : FR(sxy / sxx);
}
private FormulaResult? EvalIntercept(List<object> args)
{
if (!Pair(args, out var y, out var x)) return FormulaResult.Error("#N/A");
double mx = x.Average(), my = y.Average(), sxy = 0, sxx = 0;
for (int i = 0; i < x.Length; i++) { double dx = x[i] - mx; sxy += dx * (y[i] - my); sxx += dx * dx; }
return sxx == 0 ? FormulaResult.Error("#DIV/0!") : FR(my - sxy / sxx * mx);
}
private FormulaResult? EvalRsq(List<object> args)
{
var c = EvalCorrel(args);
return c is { IsNumeric: true } ? FR(c.NumericValue!.Value * c.NumericValue.Value) : c;
}
// STEYX(known_y, known_x) — standard error of the regression estimate.
private FormulaResult? EvalSteyx(List<object> args)
{
if (!Pair(args, out var y, out var x)) return FormulaResult.Error("#N/A");
int n = x.Length;
if (n < 3) return FormulaResult.Error("#DIV/0!");
double mx = x.Average(), my = y.Average(), sxx = 0, syy = 0, sxy = 0;
for (int i = 0; i < n; i++) { double dx = x[i] - mx, dy = y[i] - my; sxx += dx * dx; syy += dy * dy; sxy += dx * dy; }
return sxx == 0 ? FormulaResult.Error("#DIV/0!") : FR(Math.Sqrt((syy - sxy * sxy / sxx) / (n - 2)));
}
// FORECAST(x, known_y, known_x) / FORECAST.LINEAR.
private FormulaResult? EvalForecast(List<object> args)
{
if (args.Count < 3) return null;
double x0 = args[0] is FormulaResult r ? r.AsNumber() : 0;
var pair = new List<object> { args[1], args[2] };
if (!Pair(pair, out var y, out var x)) return FormulaResult.Error("#N/A");
double mx = x.Average(), my = y.Average(), sxy = 0, sxx = 0;
for (int i = 0; i < x.Length; i++) { double dx = x[i] - mx; sxy += dx * (y[i] - my); sxx += dx * dx; }
if (sxx == 0) return FormulaResult.Error("#DIV/0!");
double b = sxy / sxx;
return FR(my - b * mx + b * x0);
}
// QUARTILE(range, quart) inclusive / exclusive — reuse the percentile engine.
private FormulaResult? EvalQuartile(List<object> args, bool exclusive)
{
if (args.Count < 2) return null;
int q = args[1] is FormulaResult r ? (int)r.AsNumber() : 0;
if (q is < 0 or > 4) return FormulaResult.Error("#NUM!");
return PercentileAt(AsDoubles(args[0]), q / 4.0, exclusive);
}
private FormulaResult? EvalPercentileExc(List<object> args)
{
if (args.Count < 2) return null;
double k = args[1] is FormulaResult r ? r.AsNumber() : 0;
return PercentileAt(AsDoubles(args[0]), k, exclusive: true);
}
// Shared percentile interpolation. Inclusive: rank = k(n-1). Exclusive:
// rank = k(n+1)-1, valid only for 1/(n+1) ≤ k ≤ n/(n+1).
private static FormulaResult? PercentileAt(double[]? data, double k, bool exclusive)
{
if (data == null || data.Length == 0) return FormulaResult.Error("#NUM!");
var s = data.OrderBy(x => x).ToArray();
int n = s.Length;
double pos = exclusive ? k * (n + 1) - 1 : k * (n - 1);
if (exclusive && (pos < 0 || pos > n - 1)) return FormulaResult.Error("#NUM!");
if (!exclusive && (k < 0 || k > 1)) return FormulaResult.Error("#NUM!");
int lo = (int)Math.Floor(pos);
double frac = pos - lo;
if (lo + 1 < n) return FR(s[lo] + frac * (s[lo + 1] - s[lo]));
return FR(s[lo]);
}
}
@@ -0,0 +1,111 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
// W4d — statistical hypothesis tests (T.TEST / CHISQ.TEST / F.TEST / Z.TEST),
// each returning a p-value through the W4 distribution CDFs (RegIncBeta for the
// t/F tails, RegGammaP for chi-square, NormCdf for z). Verified against real
// Microsoft Excel.
internal partial class FormulaEvaluator
{
private static double Mean(double[] v) => v.Length == 0 ? 0 : v.Average();
private static double SampleVar(double[] v)
{
if (v.Length < 2) return 0;
double m = Mean(v);
return v.Sum(x => (x - m) * (x - m)) / (v.Length - 1);
}
// T.TEST(array1, array2, tails, type) — paired (1), two-sample equal-var (2),
// two-sample unequal-var/Welch (3).
private FormulaResult? EvalTTest(List<object> args)
{
if (args.Count < 4) return FormulaResult.Error("#VALUE!");
var a = AsDoubles(args[0]); var b = AsDoubles(args[1]);
if (a is null || b is null) return FormulaResult.Error("#VALUE!");
int tails = (int)SecNum(args, 2, 2), type = (int)SecNum(args, 3, 1);
if (tails is not (1 or 2)) return FormulaResult.Error("#NUM!");
double t, df;
if (type == 1)
{
if (a.Length != b.Length || a.Length < 2) return FormulaResult.Error("#N/A");
var d = a.Zip(b, (x, y) => x - y).ToArray();
int n = d.Length;
double sd = Math.Sqrt(SampleVar(d));
if (sd == 0) return FormulaResult.Error("#DIV/0!");
t = Mean(d) / (sd / Math.Sqrt(n));
df = n - 1;
}
else
{
int n1 = a.Length, n2 = b.Length;
if (n1 < 2 || n2 < 2) return FormulaResult.Error("#DIV/0!");
double v1 = SampleVar(a), v2 = SampleVar(b), m1 = Mean(a), m2 = Mean(b);
if (type == 2)
{
double sp = ((n1 - 1) * v1 + (n2 - 1) * v2) / (n1 + n2 - 2);
t = (m1 - m2) / Math.Sqrt(sp * (1.0 / n1 + 1.0 / n2));
df = n1 + n2 - 2;
}
else
{
double s = v1 / n1 + v2 / n2;
t = (m1 - m2) / Math.Sqrt(s);
df = s * s / (Math.Pow(v1 / n1, 2) / (n1 - 1) + Math.Pow(v2 / n2, 2) / (n2 - 1));
}
}
double p = tails * TDistRightTail(Math.Abs(t), df);
return FR(Math.Min(1.0, p));
}
// F.TEST(array1, array2) — two-tailed equality-of-variance p-value.
private FormulaResult? EvalFTest(List<object> args)
{
var a = AsDoubles(args.Count > 0 ? args[0] : null);
var b = AsDoubles(args.Count > 1 ? args[1] : null);
if (a is null || b is null || a.Length < 2 || b.Length < 2) return FormulaResult.Error("#DIV/0!");
double v1 = SampleVar(a), v2 = SampleVar(b);
if (v1 == 0 || v2 == 0) return FormulaResult.Error("#DIV/0!");
double f = v1 / v2;
double rt = 1 - FDistCdf(f, a.Length - 1, b.Length - 1);
return FR(2 * Math.Min(rt, 1 - rt));
}
// CHISQ.TEST(actual_range, expected_range) — Pearson chi-square p-value.
private FormulaResult? EvalChisqTest(List<object> args)
{
if (ToGrid(args.Count > 0 ? args[0] : null) is not { } act ||
ToGrid(args.Count > 1 ? args[1] : null) is not { } exp)
return FormulaResult.Error("#VALUE!");
int rows = act.GetLength(0), cols = act.GetLength(1);
if (exp.GetLength(0) != rows || exp.GetLength(1) != cols) return FormulaResult.Error("#N/A");
double chi2 = 0;
for (int r = 0; r < rows; r++)
for (int c = 0; c < cols; c++)
{
double e = exp[r, c]?.AsNumber() ?? 0;
if (e == 0) return FormulaResult.Error("#DIV/0!");
double diff = (act[r, c]?.AsNumber() ?? 0) - e;
chi2 += diff * diff / e;
}
int df = rows == 1 || cols == 1 ? rows * cols - 1 : (rows - 1) * (cols - 1);
if (df < 1) return FormulaResult.Error("#N/A");
return FR(1 - RegGammaP(df / 2.0, chi2 / 2.0)); // right tail
}
// Z.TEST(array, x, [sigma]) — one-tailed P(Z > z); sample stdev when sigma omitted.
private FormulaResult? EvalZTest(List<object> args)
{
var a = AsDoubles(args.Count > 0 ? args[0] : null);
if (a is null || a.Length < 1) return FormulaResult.Error("#N/A");
double x = SecNum(args, 1, 0);
double sigma = args.Count > 2 && args[2] is FormulaResult s && !s.IsBlank
? s.AsNumber() : Math.Sqrt(SampleVar(a));
if (sigma == 0) return FormulaResult.Error("#DIV/0!");
double z = (Mean(a) - x) / (sigma / Math.Sqrt(a.Length));
return FR(1 - NormCdf(z));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,454 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Prefixes Excel 2016+ dynamic-array and "modern" function names with
/// <c>_xlfn.</c> when emitting OOXML. Excel refuses to resolve bare
/// post-2016 function names (e.g. <c>SEQUENCE(5)</c> → <c>#NAME?</c>)
/// unless the XML formula uses the namespaced form (<c>_xlfn.SEQUENCE(5)</c>).
/// Excel strips the prefix back out when displaying the formula to the user,
/// so the round-trip is transparent.
///
/// Also handles <c>_xlfn._xlws.</c> (worksheet-only namespace) for FILTER
/// and <c>_xlfn.ANCHORARRAY</c> for spilled-range references (<c>A1#</c> stays
/// user-facing; the XML serialization is a separate concern handled by Excel).
/// </summary>
public static class ModernFunctionQualifier
{
// Functions that need just _xlfn.
// Source: MS-XLSX / Excel 2016+ dynamic-array + modern function catalogue.
private static readonly HashSet<string> XlfnFunctions = new(StringComparer.OrdinalIgnoreCase)
{
"SEQUENCE", "SORT", "SORTBY", "UNIQUE",
"XLOOKUP", "XMATCH",
"LET", "LAMBDA", "REDUCE", "ISOMITTED",
"MAP", "BYROW", "BYCOL", "SCAN", "MAKEARRAY",
"IFS", "SWITCH",
"MAXIFS", "MINIFS",
"CONCAT", "TEXTJOIN",
"STOCKHISTORY",
"TEXTBEFORE", "TEXTAFTER", "TEXTSPLIT",
"REGEXTEST", "REGEXEXTRACT", "REGEXREPLACE",
"ISFORMULA", "SHEET", "SHEETS",
// Engineering functions added in Excel 2013 (2007 IM* / DELTA / GESTEP
// are bare).
"IMCOSH", "IMCOT", "IMCSC", "IMCSCH", "IMSEC", "IMSECH", "IMSINH", "IMTAN",
"BITAND", "BITOR", "BITXOR", "BITLSHIFT", "BITRSHIFT",
// Financial functions added in Excel 2013 (other financials are bare).
"PDURATION", "RRI",
// Statistical / engineering distribution functions added in Excel 2010+
// (legacy NORMDIST/GAMMADIST/CHIDIST/POISSON/ERF/… are bare).
"NORM.DIST", "NORM.S.DIST", "NORM.INV", "NORM.S.INV",
"GAMMA", "GAMMALN.PRECISE", "GAMMA.DIST", "GAMMA.INV",
"CHISQ.DIST", "CHISQ.DIST.RT", "CHISQ.INV", "CHISQ.INV.RT",
"POISSON.DIST", "EXPON.DIST", "CONFIDENCE.NORM",
"ERF.PRECISE", "ERFC.PRECISE", "GAUSS", "PHI",
"BETA.DIST", "BETA.INV", "T.DIST", "T.DIST.2T", "T.DIST.RT", "T.INV", "T.INV.2T",
"F.DIST", "F.DIST.RT", "F.INV", "F.INV.RT",
"BINOM.DIST", "BINOM.INV", "NEGBINOM.DIST",
"WEIBULL.DIST", "LOGNORM.DIST", "LOGNORM.INV", "HYPGEOM.DIST",
"T.TEST", "CHISQ.TEST", "F.TEST", "Z.TEST",
"SKEW.P", "COVARIANCE.P", "COVARIANCE.S", "QUARTILE.INC", "QUARTILE.EXC",
"PERCENTILE.EXC", "FORECAST.LINEAR", "PERMUTATIONA",
"TAKE", "DROP",
"CHOOSECOLS", "CHOOSEROWS",
"ARRAYTOTEXT", "VALUETOTEXT",
"TOCOL", "TOROW",
"WRAPCOLS", "WRAPROWS",
"EXPAND",
"HSTACK", "VSTACK",
"ANCHORARRAY",
};
// Functions that need _xlfn._xlws. (dynamic-array, worksheet-only)
private static readonly HashSet<string> XlwsFunctions = new(StringComparer.OrdinalIgnoreCase)
{
"FILTER",
};
// Subset of modern functions that produce spilling dynamic-array results.
// Their CellFormula MUST carry t="array" + ref="<cellRef>" or Excel 365
// rejects the file (0x800A03EC). LET/LAMBDA can return arrays but are
// commonly used for scalars too — include them when the spill metadata
// is harmless to non-spilling outputs (Excel honors t="array" with ref=
// pointing at the single anchor cell even when the formula resolves to
// a single value). Source: Microsoft 365 dynamic-array catalogue.
private static readonly HashSet<string> DynamicArrayFunctions = new(StringComparer.OrdinalIgnoreCase)
{
"FILTER", "SORT", "SORTBY", "UNIQUE", "SEQUENCE", "RANDARRAY",
"XLOOKUP", "XMATCH",
"LET", "LAMBDA",
"MAP", "BYROW", "BYCOL", "SCAN", "MAKEARRAY",
"TAKE", "DROP", "CHOOSECOLS", "CHOOSEROWS",
"TOCOL", "TOROW", "WRAPCOLS", "WRAPROWS", "EXPAND",
"HSTACK", "VSTACK", "TRANSPOSE",
"LINEST", "LOGEST", "TREND", "GROWTH",
"TEXTSPLIT",
"ANCHORARRAY",
};
/// <summary>
/// True when the formula calls any function whose result must be written
/// with <c>t="array"</c> + <c>ref="&lt;cellRef&gt;"</c> on the cell-level
/// CellFormula element — the spill metadata Excel 365 requires for
/// dynamic-array functions. Operates on the raw formula (no leading '=').
/// Quoted string literals are skipped so a SORT call inside text doesn't
/// trigger a false positive.
/// </summary>
public static bool IsDynamicArrayFormula(string formula)
{
if (string.IsNullOrEmpty(formula)) return false;
int i = 0;
while (i < formula.Length)
{
char c = formula[i];
if (c == '"')
{
i++;
while (i < formula.Length)
{
if (formula[i] == '"')
{
if (i + 1 < formula.Length && formula[i + 1] == '"') { i += 2; continue; }
i++; break;
}
i++;
}
continue;
}
if (IsIdentStart(c) && (i == 0 || !IsIdentPrev(formula[i - 1])))
{
int start = i;
while (i < formula.Length && IsIdentCont(formula[i])) i++;
int j = i;
while (j < formula.Length && formula[j] == ' ') j++;
if (j < formula.Length && formula[j] == '(')
{
var name = formula.Substring(start, i - start);
if (DynamicArrayFunctions.Contains(name)) return true;
}
continue;
}
i++;
}
return false;
}
// Match a bare function name (identifier followed by '('), not preceded by
// a '.' or alphanumeric (so _xlfn.SEQUENCE and MYSEQUENCE are skipped),
// and not inside a quoted string literal.
private static readonly Regex FunctionCallRegex = new(
@"(?<![A-Za-z0-9_\.])([A-Za-z_][A-Za-z0-9_]*)\s*\(",
RegexOptions.Compiled);
// Collect every LET / LAMBDA parameter name in the formula. For LET the
// parameter is each odd-numbered argument except the last (name1, value1, …,
// calc); for LAMBDA it is every argument except the last (p1, …, pN, body).
private static HashSet<string> CollectLambdaParams(string formula)
{
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
int i = 0;
while (i < formula.Length)
{
char c = formula[i];
if (c == '"') // skip string literals
{
i++;
while (i < formula.Length)
{
if (formula[i] == '"') { if (i + 1 < formula.Length && formula[i + 1] == '"') { i += 2; continue; } i++; break; }
i++;
}
continue;
}
if (IsIdentStart(c) && (i == 0 || !IsIdentPrev(formula[i - 1])))
{
int s = i;
while (i < formula.Length && IsIdentCont(formula[i])) i++;
string id = formula.Substring(s, i - s);
int j = i;
while (j < formula.Length && formula[j] == ' ') j++;
bool isLet = id.Equals("LET", StringComparison.OrdinalIgnoreCase);
bool isLambda = id.Equals("LAMBDA", StringComparison.OrdinalIgnoreCase);
if ((isLet || isLambda) && j < formula.Length && formula[j] == '(')
{
var argSpans = TopLevelArgs(formula, j);
for (int k = 0; k < argSpans.Count - 1; k++)
if (isLambda || k % 2 == 0)
{
var nm = argSpans[k].Trim();
if (nm.Length > 0 && IsIdentStart(nm[0]) && nm.All(ch => IsIdentCont(ch)))
names.Add(nm);
}
}
continue;
}
i++;
}
return names;
}
// Split the parenthesised argument list starting at <paren> ('(') into the
// top-level argument substrings (respecting nested parens and strings).
private static List<string> TopLevelArgs(string formula, int paren)
{
var args = new List<string>();
int depth = 0, i = paren, start = paren + 1;
for (; i < formula.Length; i++)
{
char c = formula[i];
if (c == '"') { i++; while (i < formula.Length && formula[i] != '"') i++; continue; }
if (c == '(') depth++;
else if (c == ')') { depth--; if (depth == 0) { args.Add(formula[start..i]); break; } }
else if (c == ',' && depth == 1) { args.Add(formula[start..i]); start = i + 1; }
}
return args;
}
/// <summary>
/// Returns the formula with Excel 2016+ modern function names qualified
/// with <c>_xlfn.</c> / <c>_xlfn._xlws.</c> as required by OOXML. Leaves
/// already-qualified names, older functions, quoted string literals, and
/// non-function identifiers untouched.
/// </summary>
public static string Qualify(string formula)
{
if (string.IsNullOrEmpty(formula)) return formula;
// LET / LAMBDA parameter names are stored in the _xlpm. namespace; without
// that prefix Excel reports the workbook as corrupt. Collect every such
// name up front so all of its occurrences (declaration and uses) get the
// prefix below.
var lambdaParams = CollectLambdaParams(formula);
// Walk the string and only rewrite identifiers outside quoted strings.
// Excel formula strings are bounded by '"' with '""' as an escape.
var sb = new System.Text.StringBuilder(formula.Length + 32);
int i = 0;
while (i < formula.Length)
{
char c = formula[i];
if (c == '"')
{
// Copy the entire string literal verbatim.
sb.Append(c);
i++;
while (i < formula.Length)
{
sb.Append(formula[i]);
if (formula[i] == '"')
{
// escaped "" → consume both, stay in string
if (i + 1 < formula.Length && formula[i + 1] == '"')
{
sb.Append('"');
i += 2;
continue;
}
i++;
break;
}
i++;
}
continue;
}
// Outside a string: scan for an identifier-call.
// Use regex-on-substring is awkward; instead detect manually.
if (IsIdentStart(c) && (i == 0 || !IsIdentPrev(formula[i - 1])))
{
int start = i;
while (i < formula.Length && IsIdentCont(formula[i])) i++;
var name = formula.Substring(start, i - start);
// A LET/LAMBDA parameter (incl. when used to call a stored lambda,
// e.g. sq(7)) takes the _xlpm. prefix and shadows function names.
if (lambdaParams.Contains(name))
{
sb.Append("_xlpm.").Append(name);
continue;
}
// Skip whitespace then check for '('
int j = i;
while (j < formula.Length && formula[j] == ' ') j++;
if (j < formula.Length && formula[j] == '(')
{
if (XlwsFunctions.Contains(name))
sb.Append("_xlfn._xlws.").Append(name);
else if (XlfnFunctions.Contains(name))
sb.Append("_xlfn.").Append(name);
else
sb.Append(name);
}
else
{
sb.Append(name);
}
continue;
}
sb.Append(c);
i++;
}
return sb.ToString();
}
/// <summary>
/// Inverse of <see cref="Qualify"/> for readback: strips the
/// <c>_xlfn.</c> / <c>_xlfn._xlws.</c> prefix so users see canonical
/// function names instead of the OOXML-internal namespaced form.
/// </summary>
public static string Unqualify(string formula)
{
if (string.IsNullOrEmpty(formula)) return formula;
// Longer prefix first so we don't leave _xlws. stragglers.
var s = formula.Replace("_xlfn._xlws.", "", StringComparison.Ordinal);
s = s.Replace("_xlfn.", "", StringComparison.Ordinal);
// LET / LAMBDA parameter namespace — strip so the evaluator and the user
// see bare names (e.g. _xlpm.x → x).
s = s.Replace("_xlpm.", "", StringComparison.Ordinal);
return s;
}
/// <summary>
/// Auto-quote unquoted sheet-name references in a formula when the sheet
/// name needs single-quotes per Excel rules — i.e. starts with a digit,
/// or contains a space, or contains any of <c>[ ] : / \ ? *</c> /
/// punctuation. Already-quoted (e.g. <c>'1stQ'!A1</c>) refs are kept as-is.
/// String literals are skipped.
/// </summary>
public static string AutoQuoteSheetRefs(string formula)
{
if (string.IsNullOrEmpty(formula) || !formula.Contains('!')) return formula;
var sb = new System.Text.StringBuilder(formula.Length + 16);
int i = 0;
while (i < formula.Length)
{
char c = formula[i];
// Skip string literals verbatim
if (c == '"')
{
sb.Append(c);
i++;
while (i < formula.Length)
{
sb.Append(formula[i]);
if (formula[i] == '"')
{
if (i + 1 < formula.Length && formula[i + 1] == '"')
{
sb.Append('"'); i += 2; continue;
}
i++;
break;
}
i++;
}
continue;
}
// Skip already-quoted sheet refs verbatim
if (c == '\'')
{
sb.Append(c);
i++;
while (i < formula.Length)
{
sb.Append(formula[i]);
if (formula[i] == '\'')
{
if (i + 1 < formula.Length && formula[i + 1] == '\'')
{
sb.Append('\''); i += 2; continue;
}
i++;
break;
}
i++;
}
continue;
}
// Detect bare sheet-name token followed by '!'. A sheet name token
// here is a maximal run of [A-Za-z0-9_.] possibly preceded only by
// a non-identifier char.
if ((char.IsLetterOrDigit(c) || c == '_') &&
(i == 0 || !IsIdentPrev(formula[i - 1])))
{
int start = i;
// Greedy scan: include identifier chars and embedded spaces, as long
// as the run ultimately terminates at '!'. A bare sheet name with a
// space (e.g. `My Sheet!A1`) must be quoted as a whole, not split
// across the space.
int j = i;
while (j < formula.Length && (char.IsLetterOrDigit(formula[j]) || formula[j] == '_' || formula[j] == '.' || formula[j] == ' '))
j++;
// Trim trailing spaces from the candidate name; they can't be part of
// a sheet ref unless followed by more name chars then '!'.
int end = j;
while (end > start && formula[end - 1] == ' ') end--;
if (end < formula.Length && formula[end] == '!' && end > start)
{
var name = formula.Substring(start, end - start);
if (SheetNameNeedsQuoting(name))
sb.Append('\'').Append(name).Append('\'');
else
sb.Append(name);
i = end;
continue;
}
// No '!' terminator: only consume the leading non-space identifier
// run (preserve old behavior for plain tokens / function calls).
int k = i;
while (k < formula.Length && (char.IsLetterOrDigit(formula[k]) || formula[k] == '_' || formula[k] == '.'))
k++;
sb.Append(formula, start, k - start);
i = k;
continue;
}
sb.Append(c);
i++;
}
return sb.ToString();
}
/// <summary>
/// Quote a worksheet name for use in a formula or defined-name reference
/// (the part before <c>!</c>) when Excel requires it — i.e. the name starts
/// with a digit or contains any character outside <c>[A-Za-z0-9_]</c> (space,
/// hyphen, punctuation, …). Embedded single quotes are doubled per the OOXML
/// rule. Names that don't need quoting are returned unchanged. Without this,
/// a defined name like <c>2-Print!$A$1</c> is invalid and Excel refuses to
/// open the entire workbook (0x800A03EC).
/// </summary>
public static string QuoteSheetNameForRef(string name)
{
if (string.IsNullOrEmpty(name)) return name;
if (!SheetNameNeedsQuoting(name)) return name;
return "'" + name.Replace("'", "''") + "'";
}
private static bool SheetNameNeedsQuoting(string name)
{
if (string.IsNullOrEmpty(name)) return false;
// Starts with digit
if (char.IsDigit(name[0])) return true;
// Punctuation/special chars: space, [ ] : / \ ? *, plus '.','-','+',etc.
foreach (var ch in name)
{
if (char.IsLetterOrDigit(ch) || ch == '_') continue;
return true;
}
return false;
}
private static bool IsIdentStart(char c) => char.IsLetter(c) || c == '_';
private static bool IsIdentCont(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.';
// Prev char that would mean we're in the middle of an existing identifier
// (incl. already-qualified `_xlfn.NAME`).
private static bool IsIdentPrev(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.';
}
+571
View File
@@ -0,0 +1,571 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Direction of insertion or deletion that triggered a formula reference shift.
/// Insert directions shift refs by +1; delete directions shift refs by -1
/// and collapse refs that landed on the deleted index into <c>#REF!</c>.
/// </summary>
public enum FormulaShiftDirection
{
/// <summary>A column was inserted; cell-ref columns at or past insertIdx shift right by 1.</summary>
ColumnsRight,
/// <summary>A row was inserted; cell-ref rows at or past insertIdx shift down by 1.</summary>
RowsDown,
/// <summary>A column was deleted; refs to that column collapse to <c>#REF!</c>, refs past it shift left by 1.</summary>
ColumnsLeft,
/// <summary>A row was deleted; refs to that row collapse to <c>#REF!</c>, refs past it shift up by 1.</summary>
RowsUp,
}
/// <summary>
/// Rewrites Excel formula text after a column or row was inserted, so that
/// references that previously pointed to a moved cell continue to point to
/// the same cell.
///
/// <para>This is the regex-based "good enough" implementation (Path A). It
/// handles the common ~90% of formulas: A1 / $A$1 / $A1 / A$1 single refs,
/// A1:B5 ranges, sheet-qualified refs (Sheet2!A1, 'Sheet With Spaces'!A1),
/// and skips string literals and structured-ref bracket content. It does
/// NOT handle: cross-workbook refs ([Book]Sheet!A1), R1C1 notation,
/// whole-column (A:A) or whole-row (1:1) refs, or structured table refs
/// (Table1[Col1]) — those pass through verbatim.</para>
///
/// <para>The public API is intentionally minimal so a future tokenizer-based
/// implementation (Path B) can replace the body of <see cref="Shift"/>
/// without touching call sites or tests.</para>
/// </summary>
public static class FormulaRefShifter
{
// One regex matches either a single A1 ref or a range, optionally
// sheet-qualified. Whole-col / whole-row refs are NOT matched here —
// they require digits in r1, which is mandatory in this pattern.
//
// Capture groups:
// sheet — optional sheet name (with surrounding quotes preserved)
// c1, r1 — first cell column letters (with optional leading $) and row digits
// c2, r2 — range end (or empty for single-cell)
private static readonly Regex CellRefPattern = new(
@"(?<![\w.])" +
@"(?:(?<sheet>'(?:[^']|'')+'|[A-Za-z_][\w.]*)!)?" +
@"(?<c1>\$?[A-Z]{1,3})(?<r1>\$?\d+)" +
@"(?::(?<c2>\$?[A-Z]{1,3})(?<r2>\$?\d+))?" +
// (?![\w(]) — also reject when followed by '(' so that function names
// shaped like `LOG10` / `ATAN2` (col-letters + row-digits) are not
// misread as cell refs. Cell refs are never followed by '('.
@"(?![\w(])",
RegexOptions.Compiled);
/// <summary>
/// Rewrite cell references in a formula by remapping their row numbers
/// through an arbitrary <paramref name="oldToNewRow"/> mapping. Used by
/// move/reorder operations where the change is not a uniform +1/-1 shift
/// but a permutation of row indices (e.g. moving row 3 before row 2
/// produces the map {1→1, 2→3, 3→2}).
///
/// <para>Refs whose row is not in the map pass through unchanged. For
/// ranges, both endpoints are remapped; if the result inverts (start &gt;
/// end) the original range is returned unchanged. Sheet-scope, string-
/// literal, and structured-ref skip rules are identical to <see cref="Shift"/>.</para>
/// </summary>
public static string ApplyRowRenumberMap(
string formula,
string currentSheet,
string modifiedSheet,
IReadOnlyDictionary<int, int> oldToNewRow)
{
if (string.IsNullOrEmpty(formula) || oldToNewRow.Count == 0) return formula;
return WalkFormulaTokens(formula, chunk =>
RenumberRefsInChunk(chunk, currentSheet, modifiedSheet, oldToNewRow));
}
/// <summary>
/// Outer tokenize-skip walker shared by every <c>FormulaRefShifter</c>
/// public entry point. Streams the formula char-by-char, copying string
/// literals (with the Excel <c>""</c> doubling escape) and bracket
/// content (structured refs like <c>Table1[Col1]</c>; cross-workbook
/// prefixes like <c>[Book2]Sheet1!A1</c>) verbatim. Hands every other
/// contiguous chunk to <paramref name="chunkProcessor"/>, which runs
/// the per-match cell-ref rewrite for that semantic (shift / renumber /
/// copy-delta) and returns the rewritten chunk.
/// </summary>
private static string WalkFormulaTokens(string formula, Func<string, string> chunkProcessor)
{
var sb = new StringBuilder(formula.Length);
int i = 0;
while (i < formula.Length)
{
char ch = formula[i];
if (ch == '"')
{
sb.Append(ch); i++;
while (i < formula.Length)
{
sb.Append(formula[i]);
if (formula[i] == '"')
{
if (i + 1 < formula.Length && formula[i + 1] == '"')
{ sb.Append(formula[i + 1]); i += 2; continue; }
i++; break;
}
i++;
}
}
else if (ch == '[')
{
int depth = 0;
while (i < formula.Length)
{
char c = formula[i];
sb.Append(c);
if (c == '[') depth++;
else if (c == ']') { depth--; if (depth == 0) { i++; break; } }
i++;
}
}
else
{
int start = i;
while (i < formula.Length && formula[i] != '"' && formula[i] != '[') i++;
sb.Append(chunkProcessor(formula.AsSpan(start, i - start).ToString()));
}
}
return sb.ToString();
}
private static string RenumberRefsInChunk(
string chunk, string currentSheet, string modifiedSheet,
IReadOnlyDictionary<int, int> oldToNewRow)
{
return CellRefPattern.Replace(chunk, m =>
{
var sheetGroup = m.Groups["sheet"].Value;
string targetSheet = string.IsNullOrEmpty(sheetGroup)
? currentSheet
: (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'')
? sheetGroup[1..^1].Replace("''", "'")
: sheetGroup);
if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase))
return m.Value;
string c1 = m.Groups["c1"].Value;
string r1 = m.Groups["r1"].Value;
string c2 = m.Groups["c2"].Value;
string r2 = m.Groups["r2"].Value;
bool isRange = !string.IsNullOrEmpty(c2);
string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!";
string newR1 = RemapRow(r1, oldToNewRow);
if (!isRange) return $"{sheetPrefix}{c1}{newR1}";
string newR2 = RemapRow(r2, oldToNewRow);
// The range covers a contiguous SET of rows [r1..r2]. After
// renumber, that set must remain contiguous (and represent
// the same row content) for the new range to be a faithful
// rewrite. If the mapped set is not contiguous or doesn't
// match [min..max] of the new endpoints, fall back to the
// original text rather than write a misleading ref.
int Parse(string s) => int.Parse(s.StartsWith('$') ? s[1..] : s);
int oldR1 = Parse(r1), oldR2 = Parse(r2);
int newR1Int = Parse(newR1), newR2Int = Parse(newR2);
if (!RangeRemapStillContiguous(oldR1, oldR2, newR1Int, newR2Int, oldToNewRow))
return m.Value;
return $"{sheetPrefix}{c1}{newR1}:{c2}{newR2}";
});
}
private static bool RangeRemapStillContiguous(
int oldStart, int oldEnd, int newStart, int newEnd,
IReadOnlyDictionary<int, int> map)
{
if (oldStart > oldEnd) return false;
int newMin = Math.Min(newStart, newEnd);
int newMax = Math.Max(newStart, newEnd);
// Build the mapped set and check it equals [newMin..newMax] exactly.
var mappedSet = new HashSet<int>();
for (int i = oldStart; i <= oldEnd; i++)
{
int mapped = map.TryGetValue(i, out var n) ? n : i;
mappedSet.Add(mapped);
}
if (mappedSet.Count != (newMax - newMin + 1)) return false;
for (int i = newMin; i <= newMax; i++)
if (!mappedSet.Contains(i)) return false;
return newStart <= newEnd;
}
private static string RemapRow(string rowPart, IReadOnlyDictionary<int, int> map)
{
bool abs = rowPart.StartsWith('$');
int oldNum = int.Parse(abs ? rowPart[1..] : rowPart);
if (!map.TryGetValue(oldNum, out var newNum)) return rowPart;
return (abs ? "$" : "") + newNum;
}
/// <summary>
/// Column-axis variant of <see cref="ApplyRowRenumberMap"/>. Same skip
/// rules, sheet scope, and contiguity guard. Map keys/values are 1-based
/// column indices (A=1, B=2, ...).
/// </summary>
public static string ApplyColRenumberMap(
string formula,
string currentSheet,
string modifiedSheet,
IReadOnlyDictionary<int, int> oldToNewCol)
{
if (string.IsNullOrEmpty(formula) || oldToNewCol.Count == 0) return formula;
return WalkFormulaTokens(formula, chunk =>
RenumberColRefsInChunk(chunk, currentSheet, modifiedSheet, oldToNewCol));
}
private static string RenumberColRefsInChunk(
string chunk, string currentSheet, string modifiedSheet,
IReadOnlyDictionary<int, int> oldToNewCol)
{
return CellRefPattern.Replace(chunk, m =>
{
var sheetGroup = m.Groups["sheet"].Value;
string targetSheet = string.IsNullOrEmpty(sheetGroup)
? currentSheet
: (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'')
? sheetGroup[1..^1].Replace("''", "'")
: sheetGroup);
if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase))
return m.Value;
string c1 = m.Groups["c1"].Value;
string r1 = m.Groups["r1"].Value;
string c2 = m.Groups["c2"].Value;
string r2 = m.Groups["r2"].Value;
bool isRange = !string.IsNullOrEmpty(c2);
string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!";
string newC1 = RemapCol(c1, oldToNewCol);
if (!isRange) return $"{sheetPrefix}{newC1}{r1}";
string newC2 = RemapCol(c2, oldToNewCol);
int Idx(string s) => ColumnLettersToIndex(s.StartsWith('$') ? s[1..] : s);
int oldC1Idx = Idx(c1), oldC2Idx = Idx(c2);
int newC1Idx = Idx(newC1), newC2Idx = Idx(newC2);
if (!RangeRemapStillContiguous(oldC1Idx, oldC2Idx, newC1Idx, newC2Idx, oldToNewCol))
return m.Value;
return $"{sheetPrefix}{newC1}{r1}:{newC2}{r2}";
});
}
private static string RemapCol(string colPart, IReadOnlyDictionary<int, int> map)
{
bool abs = colPart.StartsWith('$');
string letters = abs ? colPart[1..] : colPart;
int oldIdx = ColumnLettersToIndex(letters);
if (!map.TryGetValue(oldIdx, out var newIdx)) return colPart;
return (abs ? "$" : "") + IndexToColumnLetters(newIdx);
}
/// <summary>
/// Shift relative cell references in a formula by a (deltaCol, deltaRow)
/// vector. Models Excel's "copy formula" semantics: refs without a $
/// marker shift by the delta, refs with $ stay absolute. Used when a
/// row or column is copied to a new position — the cloned formulas keep
/// their relative spatial relationships but their literal text needs to
/// reflect the new anchor cell.
///
/// <para>Sheet-scope, string-literal, and structured-ref skip rules are
/// identical to <see cref="Shift"/>. A ref whose absolute resulting row
/// or column would be &lt;= 0 collapses to <c>#REF!</c>.</para>
/// </summary>
public static string ApplyCopyDelta(
string formula,
string currentSheet,
string modifiedSheet,
int deltaCol,
int deltaRow)
{
if (string.IsNullOrEmpty(formula) || (deltaCol == 0 && deltaRow == 0)) return formula;
return WalkFormulaTokens(formula, chunk =>
DeltaShiftRefsInChunk(chunk, currentSheet, modifiedSheet, deltaCol, deltaRow));
}
private static string DeltaShiftRefsInChunk(
string chunk, string currentSheet, string modifiedSheet,
int deltaCol, int deltaRow)
{
return CellRefPattern.Replace(chunk, m =>
{
var sheetGroup = m.Groups["sheet"].Value;
string targetSheet = string.IsNullOrEmpty(sheetGroup)
? currentSheet
: (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\'')
? sheetGroup[1..^1].Replace("''", "'")
: sheetGroup);
if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase))
return m.Value;
string c1 = m.Groups["c1"].Value;
string r1 = m.Groups["r1"].Value;
string c2 = m.Groups["c2"].Value;
string r2 = m.Groups["r2"].Value;
bool isRange = !string.IsNullOrEmpty(c2);
string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!";
string? newC1 = DeltaShiftCol(c1, deltaCol);
string? newR1 = DeltaShiftRow(r1, deltaRow);
if (newC1 == null || newR1 == null) return "#REF!";
if (!isRange) return $"{sheetPrefix}{newC1}{newR1}";
string? newC2 = DeltaShiftCol(c2, deltaCol);
string? newR2 = DeltaShiftRow(r2, deltaRow);
if (newC2 == null || newR2 == null) return "#REF!";
return $"{sheetPrefix}{newC1}{newR1}:{newC2}{newR2}";
});
}
private static string? DeltaShiftCol(string colPart, int delta)
{
bool abs = colPart.StartsWith('$');
if (abs || delta == 0) return colPart;
int idx = ColumnLettersToIndex(colPart);
int newIdx = idx + delta;
if (newIdx < 1) return null;
return IndexToColumnLetters(newIdx);
}
private static string? DeltaShiftRow(string rowPart, int delta)
{
bool abs = rowPart.StartsWith('$');
if (abs || delta == 0) return rowPart;
int num = int.Parse(rowPart);
int newNum = num + delta;
if (newNum < 1) return null;
return newNum.ToString();
}
/// <summary>
/// Rewrite sheet-name prefixes when a sheet is renamed. The rewrite
/// only touches the formula's reference space — string literals
/// (<c>INDIRECT("Sheet1!A1")</c>) and bracketed structured-ref content
/// are left verbatim. <paramref name="oldRef"/> and <paramref name="newRef"/>
/// are the formula-form names with their trailing <c>!</c> already
/// applied (e.g. <c>"Sheet1!"</c> or <c>"'Sheet With Spaces'!"</c>),
/// matching how the existing rename code constructs them.
/// </summary>
public static string RenameSheetRef(string formula, string oldRef, string newRef)
{
if (string.IsNullOrEmpty(formula) || string.IsNullOrEmpty(oldRef)
|| oldRef.Equals(newRef, StringComparison.Ordinal))
return formula;
return WalkFormulaTokens(formula, chunk =>
chunk.Replace(oldRef, newRef, StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Returns the formula text rewritten so that any references targeting
/// <paramref name="modifiedSheet"/> at or past <paramref name="insertIdx"/>
/// are shifted by 1 in <paramref name="direction"/>. Refs targeting other
/// sheets, references inside string literals, and references inside
/// structured-ref brackets are returned untouched.
/// </summary>
/// <param name="formula">Formula text without a leading '=' (matching how
/// the Excel handler stores <c>CellFormula</c> content).</param>
/// <param name="currentSheet">Sheet that contains the formula. Used to
/// resolve unqualified refs.</param>
/// <param name="modifiedSheet">Sheet on which the insert happened. Refs
/// shift only when their resolved sheet equals this.</param>
/// <param name="direction">Whether a column or row was inserted.</param>
/// <param name="insertIdx">1-based column index (for ColumnsRight) or
/// 1-based row index (for RowsDown) at which the insert happened.</param>
/// <returns>The rewritten formula text. Returns the input unchanged when
/// no refs match the shift criteria.</returns>
public static string Shift(
string formula,
string currentSheet,
string modifiedSheet,
FormulaShiftDirection direction,
int insertIdx)
{
if (string.IsNullOrEmpty(formula)) return formula;
return WalkFormulaTokens(formula, chunk =>
ShiftRefsInChunk(chunk, currentSheet, modifiedSheet, direction, insertIdx));
}
private static string ShiftRefsInChunk(
string chunk, string currentSheet, string modifiedSheet,
FormulaShiftDirection direction, int insertIdx)
{
return CellRefPattern.Replace(chunk, m =>
{
var sheetGroup = m.Groups["sheet"].Value;
string targetSheet;
if (string.IsNullOrEmpty(sheetGroup))
{
targetSheet = currentSheet;
}
else if (sheetGroup.StartsWith('\'') && sheetGroup.EndsWith('\''))
{
targetSheet = sheetGroup[1..^1].Replace("''", "'");
}
else
{
targetSheet = sheetGroup;
}
if (!targetSheet.Equals(modifiedSheet, StringComparison.OrdinalIgnoreCase))
return m.Value;
string c1 = m.Groups["c1"].Value;
string r1 = m.Groups["r1"].Value;
string c2 = m.Groups["c2"].Value;
string r2 = m.Groups["r2"].Value;
string sheetPrefix = string.IsNullOrEmpty(sheetGroup) ? "" : sheetGroup + "!";
bool isRange = !string.IsNullOrEmpty(c2);
// For each axis (col, row), compute the new value or null=#REF!.
// For Insert directions, shifts never produce #REF!. For Delete
// directions, an endpoint exactly on the deleted index either
// collapses (single ref → #REF!), keeps the same row/col number
// when it is the start endpoint of a range (now points to the
// next row/col), or moves up/left when it is the end endpoint
// (range shrinks by 1).
var (newC1, newC2, colRef) = ShiftColAxis(c1, c2, isRange, direction, insertIdx);
var (newR1, newR2, rowRef) = ShiftRowAxis(r1, r2, isRange, direction, insertIdx);
if (colRef || rowRef) return "#REF!";
if (!isRange)
return $"{sheetPrefix}{newC1}{newR1}";
return $"{sheetPrefix}{newC1}{newR1}:{newC2}{newR2}";
});
}
private static (string newC1, string newC2, bool refError) ShiftColAxis(
string c1, string c2, bool isRange, FormulaShiftDirection direction, int insertIdx)
{
switch (direction)
{
case FormulaShiftDirection.ColumnsRight:
return (ShiftColPart(c1, insertIdx),
isRange ? ShiftColPart(c2, insertIdx) : c2,
false);
case FormulaShiftDirection.ColumnsLeft:
var (nc1, nc2, refErr) = DeleteShiftAxis(
c1, c2, isRange, insertIdx,
parseIdx: ColumnLettersToIndex,
formatIdx: (idx, abs) => (abs ? "$" : "") + IndexToColumnLetters(idx),
parseAbs: s => s.StartsWith('$'),
parseDigits: s => s.StartsWith('$') ? s[1..] : s);
return (nc1, nc2, refErr);
default:
return (c1, c2, false);
}
}
private static (string newR1, string newR2, bool refError) ShiftRowAxis(
string r1, string r2, bool isRange, FormulaShiftDirection direction, int insertIdx)
{
switch (direction)
{
case FormulaShiftDirection.RowsDown:
return (ShiftRowPart(r1, insertIdx),
isRange ? ShiftRowPart(r2, insertIdx) : r2,
false);
case FormulaShiftDirection.RowsUp:
var (nr1, nr2, refErr) = DeleteShiftAxis(
r1, r2, isRange, insertIdx,
parseIdx: s => int.Parse(s),
formatIdx: (idx, abs) => (abs ? "$" : "") + idx,
parseAbs: s => s.StartsWith('$'),
parseDigits: s => s.StartsWith('$') ? s[1..] : s);
return (nr1, nr2, refErr);
default:
return (r1, r2, false);
}
}
/// <summary>
/// Shared delete-direction logic for both row and column axes. Returns
/// the new endpoint strings and a refError flag set when the ref must
/// collapse to <c>#REF!</c>.
/// </summary>
private static (string n1, string n2, bool refError) DeleteShiftAxis(
string p1, string p2, bool isRange, int deletedIdx,
Func<string, int> parseIdx,
Func<int, bool, string> formatIdx,
Func<string, bool> parseAbs,
Func<string, string> parseDigits)
{
bool abs1 = parseAbs(p1);
int idx1 = parseIdx(parseDigits(p1));
if (!isRange)
{
if (idx1 == deletedIdx) return (p1, p2, true);
if (idx1 > deletedIdx) return (formatIdx(idx1 - 1, abs1), p2, false);
return (p1, p2, false);
}
bool abs2 = parseAbs(p2);
int idx2 = parseIdx(parseDigits(p2));
// Endpoint at deleted index: as start, stays at deletedIdx (now points
// to the next survivor); as end, becomes deletedIdx-1 (range shrinks).
int newIdx1 = idx1 == deletedIdx ? deletedIdx
: idx1 > deletedIdx ? idx1 - 1
: idx1;
int newIdx2 = idx2 == deletedIdx ? deletedIdx - 1
: idx2 > deletedIdx ? idx2 - 1
: idx2;
// Range collapsed past zero or inverted (e.g. A3:A3 with row 3 deleted).
if (newIdx1 > newIdx2 || newIdx2 < 1) return (p1, p2, true);
return (formatIdx(newIdx1, abs1), formatIdx(newIdx2, abs2), false);
}
private static string ShiftColPart(string colPart, int insertColIdx)
{
bool isAbs = colPart.StartsWith('$');
string letters = isAbs ? colPart[1..] : colPart;
int idx = ColumnLettersToIndex(letters);
if (idx < insertColIdx) return colPart;
return (isAbs ? "$" : "") + IndexToColumnLetters(idx + 1);
}
private static string ShiftRowPart(string rowPart, int insertRow)
{
bool isAbs = rowPart.StartsWith('$');
int num = int.Parse(isAbs ? rowPart[1..] : rowPart);
if (num < insertRow) return rowPart;
return (isAbs ? "$" : "") + (num + 1);
}
// Local copies — keep Core/ free of Handlers/ dependencies so the shifter
// can be used by any handler or tested in isolation.
private static int ColumnLettersToIndex(string letters)
{
int idx = 0;
foreach (char c in letters)
idx = idx * 26 + (char.ToUpperInvariant(c) - 'A' + 1);
return idx;
}
private static string IndexToColumnLetters(int idx)
{
var sb = new StringBuilder();
while (idx > 0)
{
int rem = (idx - 1) % 26;
sb.Insert(0, (char)('A' + rem));
idx = (idx - 1) / 26;
}
return sb.ToString();
}
}
+717
View File
@@ -0,0 +1,717 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
using DocumentFormat.OpenXml;
namespace OfficeCli.Core;
/// <summary>
/// Generic XML-based query engine (Scheme B).
/// Traverses the OpenXML element tree matching by XML local name and attributes.
/// Used as a fallback when the element type is not recognized by handler-specific (Scheme A) logic.
/// </summary>
internal static class GenericXmlQuery
{
/// <summary>
/// Query an OpenXML element tree by XML local name and attribute filters.
/// </summary>
/// <param name="root">Root element to search within</param>
/// <param name="elementName">XML local name to match (with optional namespace prefix like "a:ln")</param>
/// <param name="attributes">Attribute filters: key=value pairs. Value prefixed with "!" means not-equal.</param>
/// <param name="containsText">If set, only match elements whose InnerText contains this string</param>
/// <returns>List of matching DocumentNode results</returns>
public static List<DocumentNode> Query(OpenXmlElement root, string elementName,
Dictionary<string, string> attributes, string? containsText)
{
var results = new List<DocumentNode>();
// Parse namespace prefix if present (e.g., "a:ln" -> prefix="a", localName="ln")
string? nsPrefix = null;
string localName = elementName;
var colonIdx = elementName.IndexOf(':');
if (colonIdx > 0)
{
nsPrefix = elementName[..colonIdx];
localName = elementName[(colonIdx + 1)..];
}
string? nsUri = null;
if (nsPrefix != null)
{
CommonNamespaces.TryGetValue(nsPrefix, out nsUri);
}
Traverse(root, localName, nsUri, attributes, containsText, "", results,
new Dictionary<string, int>(), 0);
return results;
}
private static void Traverse(OpenXmlElement element, string targetLocalName,
string? targetNsUri, Dictionary<string, string> attributes, string? containsText,
string parentPath, List<DocumentNode> results,
Dictionary<string, int> parentCounters, int depth)
{
// CONSISTENCY(dos-hardening): refuse pathologically deep nesting before
// the recursion overflows the stack (an uncatchable crash that would
// escape the top-level SafeRun handler). See DocumentLimits.
DocumentLimits.EnsureDepth(depth);
var elLocalName = element.LocalName;
// Build counter key (namespace-qualified to avoid collisions)
var counterKey = elLocalName;
if (!parentCounters.ContainsKey(counterKey))
parentCounters[counterKey] = 0;
var idx = parentCounters[counterKey];
parentCounters[counterKey] = idx + 1;
var currentPath = $"{parentPath}/{elLocalName}[{idx + 1}]";
// Check if this element matches
if (MatchesElement(element, targetLocalName, targetNsUri, attributes, containsText))
{
results.Add(ElementToNode(element, currentPath));
}
// Recurse into children
var childCounters = new Dictionary<string, int>();
foreach (var child in element.ChildElements)
{
Traverse(child, targetLocalName, targetNsUri, attributes, containsText,
currentPath, results, childCounters, depth + 1);
}
}
private static bool MatchesElement(OpenXmlElement element, string targetLocalName,
string? targetNsUri, Dictionary<string, string> attributes, string? containsText)
{
// Match local name
if (!element.LocalName.Equals(targetLocalName, StringComparison.OrdinalIgnoreCase))
return false;
// Match namespace if specified
if (targetNsUri != null && element.NamespaceUri != targetNsUri)
return false;
// Match attributes
foreach (var (key, rawVal) in attributes)
{
if (key.StartsWith("__")) continue; // Skip internal pseudo-selectors
bool negate = rawVal.StartsWith("!");
var val = negate ? rawVal[1..] : rawVal;
var actual = GetAttributeValue(element, key);
bool matches = string.Equals(actual, val, StringComparison.OrdinalIgnoreCase);
if (negate ? matches : !matches) return false;
}
// Match :contains
if (containsText != null)
{
if (!element.InnerText.Contains(containsText, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
/// <summary>
/// Get attribute value from an element.
/// First checks direct XML attributes, then checks child elements with a "val" attribute
/// (common OpenXML pattern: e.g., w:jc w:val="center").
/// </summary>
public static string? GetAttributeValue(OpenXmlElement element, string attrName)
{
// 1. Check direct XML attributes (by local name)
foreach (var attr in element.GetAttributes())
{
if (attr.LocalName.Equals(attrName, StringComparison.OrdinalIgnoreCase))
return attr.Value;
}
// 2. Check child element val pattern: <child val="..."/>
foreach (var child in element.ChildElements)
{
if (child.LocalName.Equals(attrName, StringComparison.OrdinalIgnoreCase))
{
// Look for "val" attribute on this child
foreach (var attr in child.GetAttributes())
{
if (attr.LocalName.Equals("val", StringComparison.OrdinalIgnoreCase))
return attr.Value;
}
// If child exists but has no val, return its InnerText
if (!string.IsNullOrEmpty(child.InnerText))
return child.InnerText;
return ""; // Child exists but empty
}
}
return null;
}
/// <summary>
/// Convert any OpenXmlElement to a DocumentNode with attributes, text, and optional child recursion.
/// </summary>
public static DocumentNode ElementToNode(OpenXmlElement element, string path, int depth = 0)
{
var node = new DocumentNode
{
Path = path,
Type = element.LocalName,
ChildCount = element.ChildElements.Count
};
// Set text
var innerText = element.InnerText;
if (!string.IsNullOrEmpty(innerText))
{
node.Text = innerText.Length > 200 ? innerText[..200] + "..." : innerText;
}
// Preview: show XML snippet if no meaningful text
if (string.IsNullOrEmpty(innerText))
{
var outerXml = element.OuterXml;
node.Preview = outerXml.Length > 200 ? outerXml[..200] + "..." : outerXml;
}
// Populate Format with all direct XML attributes
foreach (var attr in element.GetAttributes())
{
node.Format[attr.LocalName] = attr.Value;
}
// Also include child element val attributes (common OpenXML pattern)
foreach (var child in element.ChildElements)
{
if (child.ChildElements.Count == 0)
{
foreach (var attr in child.GetAttributes())
{
if (attr.LocalName.Equals("val", StringComparison.OrdinalIgnoreCase))
{
node.Format[$"{child.LocalName}"] = attr.Value;
break;
}
}
}
}
// Recurse children if depth > 0
if (depth > 0)
{
var typeCounters = new Dictionary<string, int>();
foreach (var child in element.ChildElements)
{
var name = child.LocalName;
typeCounters.TryGetValue(name, out int idx);
node.Children.Add(ElementToNode(child, $"{path}/{name}[{idx + 1}]", depth - 1));
typeCounters[name] = idx + 1;
}
}
return node;
}
/// <summary>
/// Parse a path string like "a/b[1]/c[2]" into segments of (Name, Index).
/// Index is 1-based. If no index specified, Index is null.
/// </summary>
public static List<(string Name, int? Index)> ParsePathSegments(string path)
{
var segments = new List<(string Name, int? Index)>();
foreach (var part in path.Trim('/').Split('/'))
{
if (string.IsNullOrEmpty(part)) continue;
var bracketIdx = part.IndexOf('[');
if (bracketIdx >= 0)
{
// BUG-R36-01 fix: when ']' is missing (e.g. "slide[") the expression
// part[(bracketIdx+1)..^1] produces a negative-length range crash.
// Detect and reject unclosed brackets with a clean ArgumentException.
var closingIdx = part.IndexOf(']', bracketIdx + 1);
if (closingIdx < 0)
throw new ArgumentException($"Malformed path segment '{part}'. Bracket '[' is not closed. Expected format: name[index] or name[@attr=value].");
var name = PathAliases.Resolve(part[..bracketIdx]);
var indexStr = part[(bracketIdx + 1)..^1];
if (!int.TryParse(indexStr, out var idx))
// A predicate in the index slot (row[Score>0], row[not(V)])
// means the caller reached the single-node path navigator
// with a FILTER. get is one-node-by-path by contract;
// point at the verbs that run the selector engine.
throw new ArgumentException(AttributeFilter.IsContentFilterPath($"[{indexStr}]")
? $"'{part}' is a predicate, but this verb navigates by position and expects a numeric index (e.g. {part[..part.IndexOf('[')]}[2]). Predicates work on 'query' (read) and 'set'/'remove' (mutate matched elements)."
: $"Invalid path index '{indexStr}' in segment '{part}'. Expected a numeric index.");
if (idx < 1)
throw new ArgumentException($"Invalid path index '{idx}' in segment '{part}'. Index must be >= 1.");
segments.Add((name, idx));
}
else
{
segments.Add((PathAliases.Resolve(part), null));
}
}
return segments;
}
/// <summary>
/// Navigate an OpenXML element tree by path segments (localName + optional 1-based index).
/// Returns null if any segment cannot be resolved.
/// </summary>
public static OpenXmlElement? NavigateByPath(OpenXmlElement root, IReadOnlyList<(string Name, int? Index)> segments)
{
OpenXmlElement? current = root;
foreach (var seg in segments)
{
if (current == null) return null;
var children = current.ChildElements
.Where(e => e.LocalName.Equals(seg.Name, StringComparison.OrdinalIgnoreCase));
current = seg.Index.HasValue
? children.ElementAtOrDefault(seg.Index.Value - 1)
: children.FirstOrDefault();
}
return current;
}
/// <summary>
/// Generic attribute/property setting on an OpenXML element.
/// Tries: 1) direct XML attribute, 2) existing child element with val attribute,
/// 3) create new typed child element via SDK (validates against OpenXML schema).
/// Returns true if the property was set, false if unsupported.
/// </summary>
public static bool SetGenericAttribute(OpenXmlElement element, string key, string value)
{
// 1. Check direct XML attributes
foreach (var attr in element.GetAttributes())
{
if (attr.LocalName.Equals(key, StringComparison.OrdinalIgnoreCase))
{
element.SetAttribute(new OpenXmlAttribute(attr.Prefix, attr.LocalName, attr.NamespaceUri, value));
return true;
}
}
// 2. Check existing child element with val pattern
foreach (var child in element.ChildElements)
{
if (child.LocalName.Equals(key, StringComparison.OrdinalIgnoreCase))
{
foreach (var attr in child.GetAttributes())
{
if (attr.LocalName.Equals("val", StringComparison.OrdinalIgnoreCase))
{
child.SetAttribute(new OpenXmlAttribute(attr.Prefix, "val", attr.NamespaceUri, value));
return true;
}
}
if (child.ChildElements.Count == 0)
{
child.InnerXml = System.Security.SecurityElement.Escape(value);
return true;
}
return false;
}
}
// 3. Try creating a new typed child via SDK's type system.
// Clone parent (empty), set InnerXml with the new child — SDK will parse it
// as a typed element if valid, or OpenXmlUnknownElement if not.
return TryCreateTypedChild(element, key, value);
}
/// <summary>
/// Try to create and append a typed child element to a parent element.
/// Uses the SDK's XML parsing to validate: clones the parent (empty), injects
/// a child XML fragment, checks if the SDK recognizes it as a typed element with Val property.
/// </summary>
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075",
Justification = "Probing for a SDK-generated 'Val' property by name. OpenXml typed-element classes are referenced elsewhere and preserved by the trimmer; if trimmed away, the probe simply returns null and the caller falls through.")]
public static bool TryCreateTypedChild(OpenXmlElement parent, string key, string value)
{
var nsUri = parent.NamespaceUri;
var prefix = parent.Prefix;
if (string.IsNullOrEmpty(nsUri) || string.IsNullOrEmpty(prefix))
return false;
try
{
// Normalize boolean inputs to OOXML canonical "1"/"0" for typed
// ST_OnOff elements (w:kinsoku, w:snapToGrid, w:wordWrap,
// w:autoSpaceDE, w:bidi, etc.). The SDK parses "true"/"false"
// correctly and Word renders either way, but strict
// schema validators expect "1"/"0" and most reference docs emit
// that canonical form. Detect by probing if the typed element's
// Val property is OnOffValue / TrueFalseValue / similar.
value = NormalizeOnOffIfTyped(parent, key, value);
var escapedVal = System.Security.SecurityElement.Escape(value);
// OOXML attribute namespace handling differs by schema:
// - WordprocessingML: attributeFormDefault="qualified" → w:val
// - SpreadsheetML / DrawingML / PresentationML:
// attributeFormDefault="unqualified" → plain val (no prefix)
// Writing prefix:val to an unqualified-attribute schema produces a
// foreign extension attribute that schema validation rejects
// ("attribute 'x:val' is not declared", "required attribute 'val'
// is missing"). Probe unqualified first; if the SDK didn't bind it
// to the typed Val property (Word case), retry with the prefix.
var newChild = ProbeTypedValChild(parent, prefix, nsUri, key, escapedVal, qualifiedVal: false)
?? ProbeTypedValChild(parent, prefix, nsUri, key, escapedVal, qualifiedVal: true);
if (newChild == null)
return false;
// Schema-aware AddChild rejects elements that don't belong in this
// parent (e.g. w:snapToGrid in rPr — it's pPr-only). On rejection,
// return false so the caller can try a different container; do NOT
// fall back to AppendChild, which bypasses schema and produces
// invalid XML in the wrong parent.
if (parent is OpenXmlCompositeElement composite)
{
if (!composite.AddChild(newChild, throwOnError: false))
return false;
}
else
{
parent.AppendChild(newChild);
}
// Only after AddChild succeeded: remove any older instance the
// curated reader didn't notice. Doing this earlier would damage
// existing data on a probe that ultimately fails.
var existing = parent.ChildElements.FirstOrDefault(e =>
e.LocalName == key && !ReferenceEquals(e, newChild));
existing?.Remove();
// AddChild/AppendChild append; hoist the new child to its
// schema-correct slot so strict consumers don't reject e.g.
// <w:kern> after <w:sz> in rPr. Existing children are untouched.
SchemaOrder.Place(parent, newChild);
return true;
}
catch
{
return false;
}
}
// OOXML boolean (ST_OnOff) values: canonical is "1"/"0". SDK accepts
// "true"/"false"/"on"/"off"/"yes"/"no" but emits whatever was input.
// Normalize so the typed-child writer emits canonical "1"/"0" instead
// of leaking the user's input form to disk.
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075",
Justification = "Probing for SDK-generated 'Val' property type. Same justification as TryCreateTypedChild.")]
private static string NormalizeOnOffIfTyped(OpenXmlElement parent, string key, string value)
{
// Cheap guard: only normalize when value is one of the boolean spellings
// we know about. Anything else (numbers, enums, strings) passes through.
var v = value.Trim();
bool? truthy = v.ToLowerInvariant() switch
{
"1" or "true" or "on" or "yes" => true,
"0" or "false" or "off" or "no" => false,
_ => null,
};
if (truthy == null) return value;
// Probe the typed Val property type. Bail out cheaply on anything
// that doesn't smell like an SDK OnOff wrapper (StringValue / Int32Value
// / enum types stay as-is).
var nsUri = parent.NamespaceUri;
var prefix = parent.Prefix;
if (string.IsNullOrEmpty(nsUri) || string.IsNullOrEmpty(prefix)) return value;
try
{
var tempElement = parent.CloneNode(false);
tempElement.InnerXml = $"<{prefix}:{key} xmlns:{prefix}=\"{nsUri}\" {prefix}:val=\"1\"/>";
var newChild = tempElement.FirstChild;
if (newChild is null or OpenXmlUnknownElement) return value;
var valProp = newChild.GetType().GetProperty("Val");
if (valProp == null) return value;
// OnOffValue / TrueFalseValue / TrueFalseBlankValue all live in
// DocumentFormat.OpenXml namespace. Match by name to avoid hard
// dependency on a single nullable wrapper.
var typeName = (Nullable.GetUnderlyingType(valProp.PropertyType) ?? valProp.PropertyType).Name;
if (typeName is "OnOffValue" or "TrueFalseValue" or "TrueFalseBlankValue")
return truthy.Value ? "1" : "0";
}
catch
{
// Probe failure → preserve original (best-effort normalization).
}
return value;
}
// Build a candidate child via SDK InnerXml parse, return it only if the
// SDK recognized the element AND populated its typed Val property (i.e.
// bound the val attribute to the schema). A non-null Val proves the
// attribute namespace matched the schema; null means SDK kept val as a
// foreign extension attribute, which would later fail schema validation.
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075",
Justification = "Probing for SDK-generated 'Val' property by name. Same justification as TryCreateTypedChild.")]
private static OpenXmlElement? ProbeTypedValChild(OpenXmlElement parent, string prefix, string nsUri,
string key, string escapedVal, bool qualifiedVal)
{
var valAttr = qualifiedVal ? $"{prefix}:val" : "val";
var tempElement = parent.CloneNode(false);
tempElement.InnerXml = $"<{prefix}:{key} xmlns:{prefix}=\"{nsUri}\" {valAttr}=\"{escapedVal}\"/>";
var newChild = tempElement.FirstChild?.CloneNode(true);
if (newChild == null || newChild is OpenXmlUnknownElement)
return null;
// Schema check: only accept "scalar val" typed elements — those that
// expose a typed Val property. Composite types (w:tabs, w:rFonts,
// w:ind, w:spacing, w:numPr, ...) have no Val property; they'd
// otherwise accept the fabricated val= as an unknown extension
// attribute and silently produce invalid XML.
var valProp = newChild.GetType().GetProperty("Val");
if (valProp == null)
return null;
// Reject if SDK did not bind val to the typed property — either the
// attribute landed in the wrong namespace for this schema, or the
// value failed enum/format parsing. Either way, the caller's retry
// (or fall-through) is preferable to writing a child whose val will
// be serialized as a foreign attribute and rejected by validation.
if (valProp.GetValue(newChild) == null)
return null;
// BUG-R9A(BUG2): the SDK's typed-val binding is LENIENT — a numeric
// Val (UInt32Value, e.g. w:fitText/@w:val which is ST_DecimalNumber)
// happily holds the raw string "true", so valProp.GetValue is non-null
// even though the attribute is schema-invalid (validation later rejects
// "'true' is not a valid 'UInt32'"). Boolean tokens are only legitimate
// on boolean wrapper Val types, and those have already been normalized
// to "1"/"0" upstream (NormalizeOnOffIfTyped) before reaching this
// probe — so a "true"/"false" token still present here against a
// non-boolean Val type is exactly the bad bool→non-bool coercion.
// Reject it so the caller surfaces an UNSUPPORTED/invalid-value message
// instead of silently writing schema-invalid XML. Numeric values
// (fitText=2000) and real boolean toggles are unaffected.
var trimmedVal = escapedVal.Trim();
if ((trimmedVal.Equals("true", StringComparison.OrdinalIgnoreCase)
|| trimmedVal.Equals("false", StringComparison.OrdinalIgnoreCase)))
{
var valTypeName = (Nullable.GetUnderlyingType(valProp.PropertyType)
?? valProp.PropertyType).Name;
if (valTypeName is not ("OnOffValue" or "TrueFalseValue" or "TrueFalseBlankValue"))
return null;
}
return newChild;
}
/// <summary>
/// Try to create a new typed child element under a parent, then set multiple properties on it.
/// Used as the generic fallback for the "add" command when the element type is not recognized
/// by handler-specific logic. The element is created via SDK's XML parsing (same technique as
/// TryCreateTypedChild) but without requiring a "val" attribute — properties are set individually
/// via SetGenericAttribute after creation.
/// Returns the created element, or null if the SDK does not recognize the type.
/// </summary>
public static OpenXmlElement? TryCreateTypedElement(OpenXmlElement parent, string elementName,
Dictionary<string, string> properties, int? index = null)
{
// Support namespace prefix (e.g., "a:solidFill" → prefix="a", localName="solidFill")
string prefix;
string localName;
string nsUri;
var colonIdx = elementName.IndexOf(':');
if (colonIdx > 0)
{
var nsPrefix = elementName[..colonIdx];
localName = elementName[(colonIdx + 1)..];
if (!CommonNamespaces.TryGetValue(nsPrefix, out var resolvedUri))
return null;
prefix = nsPrefix;
nsUri = resolvedUri;
}
else
{
// Default: use parent's namespace
nsUri = parent.NamespaceUri;
prefix = parent.Prefix;
localName = elementName;
}
if (string.IsNullOrEmpty(nsUri) || string.IsNullOrEmpty(prefix))
return null;
try
{
// Build XML fragment with properties as attributes, so SDK parses them together
var attrXml = new System.Text.StringBuilder();
var declaredPrefixes = new HashSet<string> { prefix }; // element prefix already declared
foreach (var (key, value) in properties)
{
var escapedVal = System.Security.SecurityElement.Escape(value);
// Support namespace-prefixed attributes (e.g., "r:embed", "w:val")
if (key.Contains(':'))
{
var kColonIdx = key.IndexOf(':');
var attrPrefix = key[..kColonIdx];
var attrLocal = key[(kColonIdx + 1)..];
if (CommonNamespaces.TryGetValue(attrPrefix, out var attrNsUri))
{
attrXml.Append($" {attrPrefix}:{attrLocal}=\"{escapedVal}\"");
if (declaredPrefixes.Add(attrPrefix))
attrXml.Append($" xmlns:{attrPrefix}=\"{attrNsUri}\"");
}
}
else
{
attrXml.Append($" {key}=\"{escapedVal}\"");
}
}
var tempParent = parent.CloneNode(false);
tempParent.InnerXml = $"<{prefix}:{localName} xmlns:{prefix}=\"{nsUri}\"{attrXml}/>";
var newChild = tempParent.FirstChild?.CloneNode(true);
if (newChild == null || newChild is OpenXmlUnknownElement)
return null;
// For any properties that weren't set as XML attributes (e.g., child-element val patterns),
// try SetGenericAttribute as fallback
foreach (var (key, value) in properties)
{
// Skip if already set as XML attribute
var attrLocal = key.Contains(':') ? key[(key.IndexOf(':') + 1)..] : key;
if (newChild.GetAttributes().Any(a => a.LocalName.Equals(attrLocal, StringComparison.OrdinalIgnoreCase)))
continue;
SetGenericAttribute(newChild, key, value);
}
// Insert. Explicit index → manual positional insertion.
//
// No index: the placement depends on the child's schema cardinality
// and whether a same-name sibling already exists.
//
// OpenXmlCompositeElement.AddChild places by particle slot and
// REPLACES a same-name child. That is correct for a SINGLETON
// (maxOccurs == 1, e.g. <w:tblInd> in tblPr) — re-adding it should
// overwrite — but WRONG for a REPEATABLE member (e.g. a second
// <w:tblStylePr> in a table style, or <w:lvl>/<w:abstractNum>),
// which it silently collapses onto the first. The collapse is the
// latent bug the dump→batch recursive decomposition exposes; a batch
// replay of repeated same-name adds would drop them identically.
//
// So, with no index:
// • No same-name sibling yet → AddChild: positions the element in
// its correct CT_ slot and cannot collapse (nothing to merge
// onto). Preserves AddChild's ordering for particles the
// reflection-based SchemaOrder comparator can't order (e.g.
// DrawingML/Chart, where the first <c:dPt> must precede
// <c:cat>/<c:val>).
// • Same-name sibling exists AND the child is a singleton
// (maxOccurs == 1) → AddChild again: it overwrites the existing
// one (the intended replace).
// • Same-name sibling exists AND the child is repeatable (or its
// cardinality can't be resolved) → insert right AFTER the last
// same-name sibling. Repeatable members are contiguous in a CT_
// sequence, so this keeps the group together and in its correct
// slot, and never collapses.
if (index.HasValue)
{
var children = parent.ChildElements.ToList();
if (index.Value >= 0 && index.Value < children.Count)
children[index.Value].InsertBeforeSelf(newChild);
else
parent.AppendChild(newChild);
}
else
{
var sameName = parent.ChildElements.Where(e =>
e.LocalName == newChild.LocalName
&& e.NamespaceUri == newChild.NamespaceUri).ToList();
bool isSingleton = sameName.Count > 0
&& SchemaOrder.GetMaxOccurs(parent, newChild) == 1;
if (sameName.Count > 0 && !isSingleton)
{
// Repeatable (or unknown cardinality) with an existing
// sibling → append a distinct sibling; never collapse.
sameName[^1].InsertAfterSelf(newChild);
}
else if (parent is OpenXmlCompositeElement composite)
{
// Singleton re-add → drop the existing one first (true
// replace; AddChild alone refuses an already-filled
// single-occurrence slot and falls through to append).
if (isSingleton)
foreach (var ex in sameName) ex.Remove();
// First occurrence or post-removal: AddChild positions in the
// correct CT_ slot.
if (!composite.AddChild(newChild, throwOnError: false))
{
parent.AppendChild(newChild); // schema doesn't define this child
SchemaOrder.Place(parent, newChild);
}
}
else
{
parent.AppendChild(newChild);
}
}
return newChild;
}
catch
{
return null;
}
}
/// <summary>
/// Parse a CSS-like selector into element name, attributes, and containsText.
/// Reusable by all handlers for Scheme B fallback.
/// </summary>
public static (string element, Dictionary<string, string> attrs, string? containsText) ParseSelector(string selector)
{
var attrs = new Dictionary<string, string>();
string? containsText = null;
// Extract element name (before any [ or : modifier)
// Support namespace prefix with colon (e.g., "a:ln"), so find '[' or ':' that starts a pseudo-selector
var firstBracket = selector.IndexOf('[');
var pseudoIdx = selector.IndexOf(":contains(", StringComparison.Ordinal);
var emptyIdx = selector.IndexOf(":empty", StringComparison.Ordinal);
var noAltIdx = selector.IndexOf(":no-alt", StringComparison.Ordinal);
var firstMod = selector.Length;
if (firstBracket >= 0 && firstBracket < firstMod) firstMod = firstBracket;
if (pseudoIdx >= 0 && pseudoIdx < firstMod) firstMod = pseudoIdx;
if (emptyIdx >= 0 && emptyIdx < firstMod) firstMod = emptyIdx;
if (noAltIdx >= 0 && noAltIdx < firstMod) firstMod = noAltIdx;
var element = selector[..firstMod].Trim();
// Parse [attr=value] attributes (\\?! handles zsh escaping \! as !)
foreach (Match m in Regex.Matches(selector, @"\[([\w:]+)(\\?!?=)([^\]]+)\]"))
{
var key = m.Groups[1].Value;
var op = m.Groups[2].Value.Replace("\\", "");
var val = m.Groups[3].Value.Trim('\'', '"');
attrs[key] = (op == "!=" ? "!" : "") + val;
}
// Parse :contains("text")
var containsMatch = Regex.Match(selector, @":contains\(['""]?(.+?)['""]?\)");
if (containsMatch.Success) containsText = containsMatch.Groups[1].Value;
return (element, attrs, containsText);
}
private static readonly Dictionary<string, string> CommonNamespaces = new()
{
["w"] = "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
["r"] = "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
["a"] = "http://schemas.openxmlformats.org/drawingml/2006/main",
["p"] = "http://schemas.openxmlformats.org/presentationml/2006/main",
["x"] = "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
["wp"] = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
["mc"] = "http://schemas.openxmlformats.org/markup-compatibility/2006",
["c"] = "http://schemas.openxmlformats.org/drawingml/2006/chart",
["xdr"] = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
["wps"] = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape",
["wp14"] = "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing",
["v"] = "urn:schemas-microsoft-com:vml",
};
}
+102
View File
@@ -0,0 +1,102 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text;
using DocumentFormat.OpenXml.Packaging;
namespace OfficeCli.Core;
/// <summary>
/// Shared helpers for HTML preview rendering across PowerPoint, Word, and Excel handlers.
/// </summary>
internal static class HtmlPreviewHelper
{
/// <summary>
/// HTML-encode text for safe insertion into element content or double-quoted
/// attribute values: escapes &amp;, &lt;, &gt;, double-quote, and single-quote.
/// This is the plain entity-encoding shared by the PowerPoint, Excel, and chart
/// SVG renderers. (Word's preview uses a variant that additionally preserves
/// consecutive spaces as non-breaking spaces and does not escape the apostrophe —
/// see WordHandler.HtmlPreview.Css.HtmlEncode, kept separate by design.)
/// </summary>
public static string HtmlEncode(string text)
{
return text
.Replace("&", "&amp;")
.Replace("<", "&lt;")
.Replace(">", "&gt;")
.Replace("\"", "&quot;")
.Replace("'", "&#39;");
}
/// <summary>
/// Load an OpenXML part by its relationship ID and return the content as a base64 data URI.
/// Returns null if the part cannot be found or read.
/// </summary>
public static string? PartToDataUri(OpenXmlPart parentPart, string relId)
{
try
{
var part = parentPart.GetPartById(relId);
using var stream = part.GetStream();
using var ms = new MemoryStream();
stream.CopyTo(ms);
var contentType = part.ContentType ?? "image/png";
var undecodableLabel = UndecodableImageLabel(contentType);
if (undecodableLabel != null)
{
// WMF/EMF metafiles and TIFF rasters cannot be rendered by browsers in an
// <img> tag, and there is no cross-platform .NET rasterizer/transcoder
// available (the project deliberately avoids System.Drawing/GDI). Degrade
// gracefully to a self-contained SVG placeholder so the preview shows a
// clean framed box instead of a broken-image icon.
return PlaceholderDataUri(undecodableLabel);
}
return $"data:{contentType};base64,{Convert.ToBase64String(ms.ToArray())}";
}
catch
{
return null;
}
}
/// <summary>
/// Returns a short placeholder label (e.g. "WMF", "EMF", "TIFF") for image content
/// types that browsers cannot render in an &lt;img&gt; tag, or null for natively
/// renderable rasters (PNG/JPG/GIF/BMP/WebP) and SVG. Covers:
/// - WMF/EMF metafiles: image/wmf, image/x-wmf, image/emf, image/x-emf
/// - TIFF rasters: image/tiff, image/tif, image/x-tiff
/// (no cross-platform decoder/transcoder is available, so even though TIFF is a
/// raster format it degrades to a placeholder like the metafiles).
/// </summary>
private static string? UndecodableImageLabel(string contentType)
{
if (contentType.IndexOf("emf", StringComparison.OrdinalIgnoreCase) >= 0)
return "EMF";
if (contentType.IndexOf("wmf", StringComparison.OrdinalIgnoreCase) >= 0)
return "WMF";
if (contentType.IndexOf("tif", StringComparison.OrdinalIgnoreCase) >= 0)
return "TIFF";
return null;
}
/// <summary>
/// Build a base64-encoded SVG data URI placeholder for an undecodable image.
/// The SVG uses a viewBox + preserveAspectRatio so it scales to fill the host
/// &lt;img&gt; width/height, drawing a light-gray bordered rectangle with a centered
/// label (WMF/EMF/TIFF). Base64 encoding avoids any data-URI escaping concerns.
/// </summary>
private static string PlaceholderDataUri(string label)
{
var svg =
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 120 120\" " +
"preserveAspectRatio=\"xMidYMid meet\">" +
"<rect x=\"2\" y=\"2\" width=\"116\" height=\"116\" rx=\"4\" " +
"fill=\"#f5f5f5\" stroke=\"#cccccc\" stroke-width=\"2\"/>" +
"<text x=\"60\" y=\"66\" font-family=\"sans-serif\" font-size=\"22\" " +
"fill=\"#999999\" text-anchor=\"middle\">" + label + "</text>" +
"</svg>";
var b64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(svg));
return $"data:image/svg+xml;base64,{b64}";
}
}
+614
View File
@@ -0,0 +1,614 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace OfficeCli.Core;
/// <summary>
/// Headless HTML→PNG screenshot via shell-out to whichever browser is available.
/// Tries playwright CLI → Chromium-family (Chrome/Edge/Chromium) → Firefox.
/// No embedded browser engine; binary stays small.
/// </summary>
internal static class HtmlScreenshot
{
public sealed record Result(bool Ok, string Backend, string? Error);
public sealed record PaginationResult(int TotalPages, Dictionary<string, int> AnchorPageMap);
/// Run a chromium-family browser in dump-dom mode against the given HTML
/// and parse the document title for "PAGES:N|MAP:anchor=p,anchor=p,...".
/// The HTML must set the title from JS after layout settles.
/// <summary>
/// Render <paramref name="htmlPath"/> in a chrome-family browser with a
/// virtual-time budget (so async JS such as mermaid.js finishes) and return
/// the final serialized DOM, or null if no chrome-family browser is found or
/// the run fails. Callers extract whatever they need from the DOM (title,
/// a rendered &lt;svg&gt;, …).
/// </summary>
public static string? DumpDom(string htmlPath, int timeoutMs = 60000, string[]? extraArgs = null)
{
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
var bin = FindChrome();
if (bin == null) return null;
var args = new List<string>
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--virtual-time-budget=15000",
"--timeout=20000", // wall-clock backstop: a stalled resource is not rescued by virtual time (issue #181)
};
if (extraArgs != null) args.AddRange(extraArgs);
args.Add("--dump-dom");
args.Add(url);
try
{
var psi = new ProcessStartInfo
{
FileName = bin,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var p = Process.Start(psi);
if (p == null) return null;
var stdout = p.StandardOutput.ReadToEnd();
if (!p.WaitForExit(timeoutMs)) { try { p.Kill(true); } catch { } return null; }
return stdout;
}
catch { return null; }
}
/// <summary>True when a chrome-family browser (Chrome/Chromium/Edge) is available.</summary>
public static bool HasChromeFamily() => FindChrome() != null;
/// <summary>
/// Screenshot <paramref name="htmlPath"/> in a chrome-family browser at an exact
/// pixel window size, with a virtual-time budget (so async JS such as mermaid.js
/// finishes before capture) and a HiDPI scale factor for crisp raster output.
/// Returns true on a non-empty PNG at <paramref name="outPath"/>.
/// </summary>
public static bool CaptureChromeSized(string htmlPath, string outPath, int w, int h,
int scale = 2, int timeoutMs = 60000)
{
var bin = FindChrome();
if (bin == null) return false;
outPath = Path.GetFullPath(outPath);
var outDir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir);
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
var args = new[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
$"--force-device-scale-factor={scale}",
$"--window-size={w},{h}",
"--virtual-time-budget=15000",
"--timeout=20000", // wall-clock backstop: a stalled resource is not rescued by virtual time (issue #181)
"--default-background-color=00000000",
$"--screenshot={outPath}",
url,
};
var (ok, _) = RunBinary(bin, args);
return ok && File.Exists(outPath) && new FileInfo(outPath).Length > 0;
}
public static PaginationResult? GetPaginationFromDom(string htmlPath, int timeoutMs = 60000)
{
var stdout = DumpDom(htmlPath, timeoutMs);
if (stdout == null) return null;
try
{
var m = System.Text.RegularExpressions.Regex.Match(stdout, @"<title>PAGES:(\d+)(?:\|MAP:([^<]*))?</title>");
if (!m.Success || !int.TryParse(m.Groups[1].Value, out var n)) return null;
var map = new Dictionary<string, int>();
if (m.Groups[2].Success && m.Groups[2].Value.Length > 0)
{
foreach (var pair in m.Groups[2].Value.Split(','))
{
var eq = pair.IndexOf('=');
if (eq > 0 && int.TryParse(pair[(eq + 1)..], out var pgNum))
map[pair[..eq]] = pgNum;
}
}
return new PaginationResult(n, map);
}
catch { return null; }
}
public static int? GetPageCountFromDom(string htmlPath, int timeoutMs = 60000)
=> GetPaginationFromDom(htmlPath, timeoutMs)?.TotalPages;
/// <summary>
/// Pick a column count for a <paramref name="count"/>-item thumbnail contact
/// sheet so the composed image is roughly square — used by `--grid auto`.
/// Each cell is <paramref name="cellW"/>×<paramref name="cellH"/>; the grid
/// image has aspect (rows·cellH)/(cols·cellW) = (count/cols²)·(cellH/cellW),
/// so cols ≈ √(count·aspect) makes it ≈ 1. Portrait pages (aspect &gt; 1) get
/// more columns, landscape slides (aspect &lt; 1) fewer. Clamped to [1, count].
/// </summary>
public static int AutoGridColumns(int count, double cellW, double cellH)
{
if (count <= 1) return 1;
double aspect = cellH / Math.Max(1.0, cellW);
int cols = (int)Math.Round(Math.Sqrt(count * aspect));
return Math.Clamp(cols, 1, count);
}
public static Result Capture(string htmlPath, string outPath, int width = 1600, int height = 1200)
{
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
outPath = Path.GetFullPath(outPath);
var outDir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir);
// Cap to <= 1920px to stay within multi-image LLM limits.
var (w, h) = CapDim(width, height, 1920);
string? lastError = null;
foreach (var (name, runner) in Backends())
{
var (ok, err) = runner(url, outPath, w, h);
if (ok && File.Exists(outPath) && new FileInfo(outPath).Length > 0)
return new Result(true, name, null);
if (err != null) lastError = $"{name}: {err}";
}
return new Result(false, "", lastError ?? "no headless backend available");
}
/// <summary>
/// Screenshot only the region covered by the given <c>data-path</c> targets
/// (a single element, or several whose union bounding box is captured —
/// e.g. the two corner cells of an xlsx range). Three passes, all plain
/// chrome invocations: pass 1 injects a measure script that resolves the
/// targets (un-hiding an inactive sheet tab's content) and reports the
/// union rect + document extent through the document title; pass 2
/// captures the WHOLE page at a viewport covering the target (layout is
/// identical to the measure pass, so the coordinates hold — no transform
/// tricks, which Chrome's paint culling and ~500px window clamp defeat);
/// pass 3 loads that PNG in a bare wrapper page offset by the rect origin
/// and captures at exactly the rect's size — a pure pixel crop.
/// Chrome-family only (the injection needs a scriptable engine); returns
/// a Result whose Error is "clip_target_not_found" when no target matched
/// a rendered element.
/// </summary>
public static Result CaptureClipped(string htmlPath, string outPath, IReadOnlyList<string> dataPaths,
int padPx = 0, int scale = 2)
{
if (FindChrome() == null)
return new Result(false, "", "clip mode requires a Chrome-family browser (Chrome/Edge/Chromium)");
var html = File.ReadAllText(htmlPath);
var pathsJs = "[" + string.Join(",", dataPaths.Select(p =>
"'" + p.Replace("\\", "\\\\").Replace("'", "\\'") + "'")) + "]";
// Shared prelude: resolve targets and force-display hidden ancestors so
// an inactive sheet's cells become measurable/visible.
// A data-path can match several nodes (the pptx sidebar thumbnails
// clone slide content), so pick the VISIBLE match with the largest
// box; only when every match is hidden (an inactive xlsx sheet tab)
// un-hide the first one's display:none ancestors and use it.
var prelude =
"var _clipPaths=" + pathsJs + ";" +
"function _clipPick(p){var cands=document.querySelectorAll('[data-path=\"'+p+'\"]');" +
"var best=null,bestA=0;cands.forEach(function(el){var r=el.getBoundingClientRect();" +
"var a=r.width*r.height;if(a>bestA){bestA=a;best=el;}});" +
"if(best)return best;if(cands.length===0)return null;" +
"var el=cands[0];for(var an=el;an&&an!==document.documentElement;an=an.parentElement){" +
"if(getComputedStyle(an).display==='none')an.style.display='block';}return el;}" +
"function _clipEls(){var els=[];_clipPaths.forEach(function(p){" +
"var el=_clipPick(p);if(el)els.push(el);});return els;}";
// The rect is reported via console.log -> --enable-logging=stderr in
// the SAME chrome invocation that captures the full page: headless
// dump-dom uses a different, fixed viewport (~1024x513, --window-size
// ignored) than screenshot mode, and any preview whose layout depends
// on viewport height (the xlsx sheet stack) measures differently
// there — a separate measure pass can never be trusted. One process,
// one layout, one frame. The settle delay runs late in the virtual
// time budget so font loads and the page's own layout JS (pptx shape
// positioning) have finished; virtual time fast-forwards it, so the
// wall-clock cost is nil. No requestAnimationFrame anywhere: rAF
// fires unreliably under --virtual-time-budget.
var measureScript =
"<script>function _clipRun(){" +
prelude +
"var els=_clipEls();if(els.length===0){console.log('CLIPRECT:NOTFOUND');return;}" +
"var x1=1e9,y1=1e9,x2=-1e9,y2=-1e9;els.forEach(function(el){var r=el.getBoundingClientRect();" +
"var x=r.left+window.scrollX,y=r.top+window.scrollY;" +
"if(x<x1)x1=x;if(y<y1)y1=y;if(x+r.width>x2)x2=x+r.width;if(y+r.height>y2)y2=y+r.height;});" +
"console.log('CLIPRECT:'+Math.floor(x1)+','+Math.floor(y1)+','+Math.ceil(x2-x1)+','+Math.ceil(y2-y1)" +
"+','+window.innerWidth+','+window.innerHeight);}" +
// Un-hide EARLY, measure LATE: revealing an inactive sheet's
// content triggers the page's own observers (resize/height
// adjustments), so a rect taken in the same tick as the un-hide
// reads the pre-adjustment layout while the paint shows the
// post-adjustment one. The 11-second virtual gap lets every
// observer settle before the coordinates are read.
"function _clipArm(){setTimeout(function(){_clipEls();},800);setTimeout(_clipRun,12000);}" +
"if(document.readyState==='complete')_clipArm();else window.addEventListener('load',_clipArm);</script>";
string Inject(string doc, string script) =>
doc.Contains("</body>", StringComparison.OrdinalIgnoreCase)
? doc.Replace("</body>", script + "</body>")
: doc + script;
var measurePath = htmlPath + ".clipmeasure.html";
var fullPath = htmlPath + ".clipfull.png";
var wrapPath = htmlPath + ".clipwrap.html";
try
{
File.WriteAllText(measurePath, Inject(html, measureScript));
// Combined measure + whole-page capture. When the target extends
// past the current viewport, retry AT the enlarged viewport (the
// enlargement can reflow viewport-dependent layout, so the rect
// must come from the same run that painted the final PNG).
// Headless chrome's JS/layout viewport is SHORTER than the
// requested window (87px of window chrome on macOS; 0 on some
// platforms), and the final --screenshot paint re-lays-out at the
// FULL window height — so a rect measured by page JS lives in a
// different vertical layout than the painted pixels whenever the
// page's layout depends on viewport height (the xlsx sheet
// stack). Self-calibrate: the measure run reports its own
// innerHeight, giving delta = window - viewport; the measure runs
// with the window ENLARGED by delta (JS layout = target height)
// and the capture runs with the plain window (paint layout = the
// same target height). No hardcoded platform constant.
const int maxViewport = 4000;
int vw = 800, vh = 600, x = 0, y = 0, w = 0, h = 0, deltaH = 0;
for (var attempt = 0; ; attempt++)
{
var (ok, stderr) = RunChromeCapture(measurePath, fullPath, vw, vh + deltaH, scale);
if (!ok)
return new Result(false, "chrome", "measure run failed");
var rectM = System.Text.RegularExpressions.Regex.Match(
stderr ?? "", @"CLIPRECT:(-?\d+),(-?\d+),(\d+),(\d+),(\d+),(\d+)");
if (!rectM.Success)
{
if ((stderr ?? "").Contains("CLIPRECT:NOTFOUND"))
return new Result(false, "chrome", "clip_target_not_found");
return new Result(false, "chrome", "clip rect not reported (console logging unavailable?)");
}
x = Math.Max(0, int.Parse(rectM.Groups[1].Value) - padPx);
y = Math.Max(0, int.Parse(rectM.Groups[2].Value) - padPx);
w = int.Parse(rectM.Groups[3].Value) + 2 * padPx;
h = int.Parse(rectM.Groups[4].Value) + 2 * padPx;
var innerH = int.Parse(rectM.Groups[6].Value);
if (w <= 0 || h <= 0) return new Result(false, "chrome", "clip target has an empty bounding box");
var newDelta = (vh + deltaH) - innerH;
var needW = Math.Max(vw, x + w);
var needH = Math.Max(vh, y + h);
if (needW > maxViewport || needH > maxViewport)
return new Result(false, "chrome",
$"clip target extends to ({needW},{needH}) CSS px — beyond the {maxViewport}px capture ceiling");
var settled = needW == vw && needH == vh && innerH == vh;
if (settled || attempt >= 4) { vw = needW; vh = needH; break; }
vw = needW; vh = needH; deltaH = Math.Max(0, newDelta);
}
// Oversized targets drop the HiDPI factor to stay within the
// multi-image LLM ceiling; device pixels only, CSS layout intact.
if (Math.Max(w, h) * scale > 1920) scale = 1;
// The whole-page capture: plain window (vw, vh) paints at exactly
// the (vw, vh) layout the calibrated measure ran in.
{
var (okF, _) = RunChromeCapture(measurePath, fullPath, vw, vh, scale);
if (!okF || !File.Exists(fullPath) || new FileInfo(fullPath).Length == 0)
return new Result(false, "chrome", "whole-page capture failed");
}
var fullW = vw;
var fullH = vh;
try
{
// Pass 3: pure pixel crop — show the full PNG offset by the
// rect origin in a bare page and capture at the rect's size.
// The PNG already carries the HiDPI scale, so the wrapper
// renders it at its CSS size and captures at the same factor.
var fullUri = new Uri(Path.GetFullPath(fullPath)).AbsoluteUri;
File.WriteAllText(wrapPath,
"<!DOCTYPE html><html><head><title>clip</title><style>html,body{margin:0;padding:0;overflow:hidden}</style></head>" +
$"<body><img src=\"{fullUri}\" style=\"position:absolute;left:{-x}px;top:{-y}px;width:{fullW}px;height:{fullH}px\"></body></html>");
var ok = CaptureChromeSized(wrapPath, outPath, w, h, scale);
return ok
? new Result(true, "chrome", null)
: new Result(false, "chrome", "clipped capture produced no image");
}
finally
{
try { File.Delete(fullPath); } catch { /* ignore */ }
try { File.Delete(wrapPath); } catch { /* ignore */ }
}
}
finally { try { File.Delete(measurePath); } catch { /* ignore */ } }
}
/// <summary>
/// Parse a `--clip` target into the data-path list CaptureClipped consumes.
/// Two forms: an xlsx range `/Sheet1/A1:C3` (also `Sheet1!A1:C3`) resolves
/// to its two corner cell paths; anything else is a single element
/// data-path used verbatim (`/slide[1]/shape[2]`, `/body/table[1]`, …).
/// </summary>
public static List<string> ResolveClipDataPaths(string clip)
{
var c = clip.Trim();
// Sheet1!A1:C3 → /Sheet1/A1:C3
var bang = c.IndexOf('!');
if (bang > 0 && !c.StartsWith('/'))
c = "/" + c[..bang] + "/" + c[(bang + 1)..];
var m = System.Text.RegularExpressions.Regex.Match(
c, @"^(/[^/]+)/([A-Za-z]{1,3}\d+):([A-Za-z]{1,3}\d+)$");
if (m.Success)
{
return
[
$"{m.Groups[1].Value}/{m.Groups[2].Value.ToUpperInvariant()}",
$"{m.Groups[1].Value}/{m.Groups[3].Value.ToUpperInvariant()}",
];
}
return [c];
}
private static IEnumerable<(string, Func<string, string, int, int, (bool, string?)>)> Backends()
{
yield return ("playwright", TryPlaywright);
yield return ("chrome", TryChrome);
yield return ("firefox", TryFirefox);
}
private static (int, int) CapDim(int w, int h, int limit)
{
var m = Math.Max(w, h);
if (m <= limit) return (w, h);
var s = (double)limit / m;
return (Math.Max(1, (int)(w * s)), Math.Max(1, (int)(h * s)));
}
// ----- Playwright CLI -----------------------------------------------------------------
private static (bool, string?) TryPlaywright(string url, string outPath, int w, int h)
{
var pw = WhichFirst("playwright");
if (pw == null) return (false, null);
var args = new[] { "screenshot", $"--viewport-size={w},{h}", "--full-page", url, outPath };
return RunBinary(pw, args);
}
// ----- Chromium family ---------------------------------------------------------------
private static (bool, string?) TryChrome(string url, string outPath, int w, int h)
{
var bin = FindChrome();
if (bin == null) return (false, null);
var args = new[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
$"--window-size={w},{h}",
// Without these caps, new-headless --screenshot waits for the
// page's external resources (CDN fonts / KaTeX css+js) to settle;
// a slow or stalled request makes it sit until RunBinary's
// 2-minute kill — per page, which reads as a hang on headless
// Linux (issue #181). The virtual-time budget lets async JS
// (KaTeX, mermaid) finish and bounds slow-but-completing loads;
// --timeout is the wall-clock backstop for a STALLED request
// (connection accepted, never answered), which virtual time
// does NOT rescue — verified against a never-responding server.
"--virtual-time-budget=15000",
"--timeout=20000",
$"--screenshot={outPath}",
url,
};
return RunBinary(bin, args);
}
private static string? FindChrome()
{
string[] names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
"chrome", "microsoft-edge", "microsoft-edge-stable", "msedge"];
var pathHit = WhichFirst(names);
if (pathHit != null) return pathHit;
var abs = new List<string>();
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
abs.AddRange(new[]
{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
});
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
abs.AddRange(new[]
{
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
"/snap/bin/chromium", "/snap/bin/google-chrome",
"/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable",
});
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string[] roots = [
Environment.GetEnvironmentVariable("PROGRAMFILES") ?? @"C:\Program Files",
Environment.GetEnvironmentVariable("PROGRAMFILES(X86)") ?? @"C:\Program Files (x86)",
Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? "",
];
string[] suffixes = [
@"Google\Chrome\Application\chrome.exe",
@"Chromium\Application\chrome.exe",
@"Microsoft\Edge\Application\msedge.exe",
];
foreach (var r in roots)
if (!string.IsNullOrEmpty(r))
foreach (var s in suffixes) abs.Add(Path.Combine(r, s));
}
return abs.FirstOrDefault(File.Exists);
}
// ----- Firefox -----------------------------------------------------------------------
private static (bool, string?) TryFirefox(string url, string outPath, int w, int h)
{
var bin = FindFirefox();
if (bin == null) return (false, null);
// Firefox: `--headless --screenshot=<out> --window-size=W,H <URL>`.
// Note: no `=new` headless variant; --force-device-scale-factor not supported.
var args = new[] { "--headless", $"--screenshot={outPath}", $"--window-size={w},{h}", url };
return RunBinary(bin, args);
}
private static string? FindFirefox()
{
var pathHit = WhichFirst("firefox", "firefox-esr");
if (pathHit != null) return pathHit;
var abs = new List<string>();
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
abs.AddRange(new[]
{
"/Applications/Firefox.app/Contents/MacOS/firefox",
"/Applications/Firefox Developer Edition.app/Contents/MacOS/firefox",
"/Applications/Firefox Nightly.app/Contents/MacOS/firefox",
});
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
abs.AddRange(new[] { "/usr/bin/firefox", "/usr/bin/firefox-esr", "/snap/bin/firefox" });
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
foreach (var r in new[]
{
Environment.GetEnvironmentVariable("PROGRAMFILES") ?? @"C:\Program Files",
Environment.GetEnvironmentVariable("PROGRAMFILES(X86)") ?? @"C:\Program Files (x86)",
})
if (!string.IsNullOrEmpty(r)) abs.Add(Path.Combine(r, @"Mozilla Firefox\firefox.exe"));
}
return abs.FirstOrDefault(File.Exists);
}
// ----- Helpers -----------------------------------------------------------------------
/// <summary>Find the first of <paramref name="names"/> on PATH (honouring
/// Windows PATHEXT). Shared executable lookup — same mechanism used to detect
/// playwright/chrome/firefox, so callers like the mermaid renderer detect mmdc
/// identically.</summary>
public static string? Which(params string[] names) => WhichFirst(names);
private static string? WhichFirst(params string[] names)
{
var pathSep = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':';
var pathExt = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").Split(';')
: new[] { "" };
var paths = (Environment.GetEnvironmentVariable("PATH") ?? "").Split(pathSep);
foreach (var name in names)
{
foreach (var dir in paths)
{
if (string.IsNullOrEmpty(dir)) continue;
foreach (var ext in pathExt)
{
var candidate = Path.Combine(dir, name + ext);
if (File.Exists(candidate)) return candidate;
}
}
}
return null;
}
/// <summary>
/// One chrome run that paints a screenshot AND returns stderr, where
/// --enable-logging=stderr surfaces the page's console.log lines — the
/// channel CaptureClipped uses to read the measured rect out of the very
/// same invocation that produced the pixels. Both streams are drained
/// asynchronously before the bounded wait (undrained pipes deadlock at
/// ~64KB).
/// </summary>
private static (bool Ok, string? Stderr) RunChromeCapture(string htmlPath, string outPath, int w, int h, int scale)
{
var bin = FindChrome();
if (bin == null) return (false, null);
outPath = Path.GetFullPath(outPath);
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
try
{
var psi = new ProcessStartInfo
{
FileName = bin,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in new[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
"--enable-logging=stderr",
"--v=0",
$"--force-device-scale-factor={scale}",
$"--window-size={w},{h}",
"--virtual-time-budget=15000",
"--timeout=20000",
$"--screenshot={outPath}",
url,
}) psi.ArgumentList.Add(a);
using var p = Process.Start(psi);
if (p == null) return (false, null);
var outTask = p.StandardOutput.ReadToEndAsync();
var errTask = p.StandardError.ReadToEndAsync();
if (!p.WaitForExit(120_000))
{
try { p.Kill(true); } catch { /* ignore */ }
return (false, null);
}
return (p.ExitCode == 0, errTask.GetAwaiter().GetResult());
}
catch { return (false, null); }
}
private static (bool, string?) RunBinary(string bin, string[] args)
{
try
{
var psi = new ProcessStartInfo
{
FileName = bin,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
using var p = Process.Start(psi);
if (p == null) return (false, "process did not start");
if (!p.WaitForExit(120_000))
{
try { p.Kill(true); } catch { /* ignore */ }
return (false, "timeout after 120s");
}
if (p.ExitCode != 0)
{
var stderr = p.StandardError.ReadToEnd();
var lastLine = stderr.Trim().Split('\n').LastOrDefault() ?? $"exit {p.ExitCode}";
return (false, lastLine);
}
return (true, null);
}
catch (Exception e)
{
return (false, e.Message);
}
}
}
@@ -0,0 +1,89 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Cross-handler allowlist for user-supplied hyperlink URI schemes.
///
/// PowerPoint/Excel/Word would otherwise happily write any URI a caller
/// hands in (javascript:, file://, data:, vbscript:) into the document's
/// .rels Target. That round-trips cleanly but lets a malicious caller plant
/// click-bait that triggers script execution or local-file exfiltration on
/// recipients who follow the link. The OOXML format itself does not gate
/// scheme — every Office product applies its own runtime warning UI on top
/// — so we reject unsafe schemes at write time and keep the document clean.
///
/// Handler-internal targets (PowerPoint's ppaction://, slide://, named
/// actions like firstslide/nextslide, fragment anchors like #_ftn1,
/// in-workbook references like Sheet!A1, or any non-absolute URI) are
/// resolved by the handler before this validator is consulted; callers only
/// pass strings here once they have been classified as an external URI.
/// </summary>
public static class HyperlinkUriValidator
{
// Schemes that survive an Office "is this link safe?" prompt without
// user warnings. http/https/mailto are the everyday cases; ftp/sms/tel
// /news are the standard PowerPoint "Action button" set; ppaction is
// PowerPoint's internal navigation pseudo-scheme and is allowed so a
// caller can paste a ppaction:// URI it read from another file.
private static readonly HashSet<string> AllowedSchemes = new(StringComparer.OrdinalIgnoreCase)
{
"http",
"https",
"mailto",
"ftp",
"ftps",
"sftp",
"news",
"tel",
"sms",
"ppaction",
// BUG-R4B(BUG5): file: is a legitimate, low-risk hyperlink scheme that
// real-world documents use to link local/network resources
// (file:///C:/...). Unlike javascript:/data:/vbscript:, it does not
// execute script or exfiltrate data — Office prompts on follow like any
// external link. Allowing it lets dump→replay round-trip file-target
// hyperlinks instead of emitting a command the batch rejects.
// javascript:/data:/vbscript: stay rejected (omitted from this set).
"file",
// BUG-DUMP-ABOUTBLANK: `about:` (RFC 6694) is an inert scheme — Office
// never navigates it and it executes no script. Word natively writes
// about:blank as the placeholder Target for a hyperlink with no real
// destination (a styled link whose URL was cleared). Rejecting it dropped
// the <w:hyperlink> wrapper on dump→batch, so the link text lost its
// hyperlink styling. Same low-risk rationale as file: above.
"about",
};
/// <summary>
/// Validate an external hyperlink URI's scheme. Throws ArgumentException
/// with a deterministic, agent-readable message when the scheme is not
/// in the allowlist. Empty / null input is a no-op so the caller's own
/// "missing URL" diagnostic remains the surfaced error.
/// </summary>
/// <summary>
/// Non-throwing predicate: true when <paramref name="url"/> is an absolute
/// URI whose scheme is in the allowlist. Used by the HTML preview, which
/// must not throw on an authored-in HYPERLINK() formula but also must not
/// emit a javascript:/data:/file: href as an XSS sink.
/// </summary>
public static bool IsSafeScheme(string url)
{
if (string.IsNullOrEmpty(url)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
return !string.IsNullOrEmpty(uri.Scheme) && AllowedSchemes.Contains(uri.Scheme);
}
public static void RequireSafeScheme(string url, string contextKey = "link")
{
if (string.IsNullOrEmpty(url)) return;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return; // not absolute → handler-internal path, not our concern
var scheme = uri.Scheme;
if (string.IsNullOrEmpty(scheme)) return;
if (AllowedSchemes.Contains(scheme)) return;
throw new ArgumentException(
$"Invalid {contextKey} URL scheme '{scheme}:': only http, https, mailto, ftp, ftps, sftp, news, tel, sms, file, about, and ppaction targets are accepted. " +
"javascript:, data:, vbscript:, and similar schemes are rejected to prevent click-bait redirection in shared documents.");
}
}
+129
View File
@@ -0,0 +1,129 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Represents where to insert an element: by index, after an anchor, or before an anchor.
/// At most one field is set. All null = append to end.
/// </summary>
public class InsertPosition
{
public int? Index { get; init; }
public string? After { get; init; }
public string? Before { get; init; }
public static InsertPosition AtIndex(int idx) => new() { Index = idx };
public static InsertPosition AfterElement(string path) => new() { After = path };
public static InsertPosition BeforeElement(string path) => new() { Before = path };
/// <summary>
/// Resolve After/Before anchor to a 0-based index among children.
/// If this is already an Index or null, returns Index as-is.
/// anchorFinder: given the anchor path, returns the 0-based index of that element among siblings, or throws.
/// childCount: total number of children of the relevant type.
/// </summary>
public int? Resolve(Func<string, int> anchorFinder, int childCount)
{
if (Index.HasValue) return Index;
if (After != null)
{
var anchorIdx = anchorFinder(After);
return anchorIdx + 1 >= childCount ? null : anchorIdx + 1; // null = append
}
if (Before != null)
{
return anchorFinder(Before);
}
return null; // append
}
}
/// <summary>
/// Shared guard for handlers that do not support the `view text --range`
/// cell-range subset (docx/pptx/plugins). Kept next to the interface so the
/// error text stays identical across handlers.
/// </summary>
public static class ViewRangeGuard
{
public static void RejectTextRange(string? range, string format)
{
if (range == null) return;
throw new CliException(
$"--range on view text is only supported for xlsx (cell ranges like 'Sheet1!A1:C10'). For {format}, use --start/--end to bound the output.")
{ Code = "invalid_value" };
}
}
/// <summary>
/// Common interface for all document types (Word/Excel/PowerPoint).
/// Each handler implements the three-layer architecture:
/// - Semantic layer: view (text/annotated/outline/stats/issues)
/// - Query layer: get, query, set
/// - Raw layer: raw XML access
/// </summary>
public interface IDocumentHandler : IDisposable
{
// === Semantic Layer ===
// range: xlsx-only cell-range subset ('Sheet1!A1:C10' or '/Sheet1/A1:C10');
// docx/pptx throw invalid_value when non-null (use --start/--end there).
string ViewAsText(int? startLine = null, int? endLine = null, int? maxLines = null, HashSet<string>? cols = null, string? range = null);
string ViewAsAnnotated(int? startLine = null, int? endLine = null, int? maxLines = null, HashSet<string>? cols = null);
string ViewAsOutline();
string ViewAsStats();
// === Structured JSON variants (for --json mode) ===
System.Text.Json.Nodes.JsonNode ViewAsStatsJson();
System.Text.Json.Nodes.JsonNode ViewAsOutlineJson();
System.Text.Json.Nodes.JsonNode ViewAsTextJson(int? startLine = null, int? endLine = null, int? maxLines = null, HashSet<string>? cols = null, string? range = null);
List<DocumentIssue> ViewAsIssues(string? issueType = null, int? limit = null);
// === Query Layer ===
DocumentNode Get(string path, int depth = 1);
List<DocumentNode> Query(string selector);
/// <summary>
/// Returns list of prop names that were not applied (unsupported for this element type).
/// </summary>
List<string> Set(string path, Dictionary<string, string> properties);
string Add(string parentPath, string type, InsertPosition? position, Dictionary<string, string> properties);
/// <summary>
/// Remove element at path. Returns an optional warning message (e.g. formula cells affected by shift).
/// When <paramref name="properties"/> carries trackChange.* keys (Word only, Run/Paragraph in Phase 4),
/// the removal is recorded as a w:del revision instead of physically deleted.
/// </summary>
string? Remove(string path, Dictionary<string, string>? properties = null);
string Move(string sourcePath, string? targetParentPath, InsertPosition? position, Dictionary<string, string>? properties = null);
string CopyFrom(string sourcePath, string targetParentPath, InsertPosition? position);
// === Raw Layer ===
string Raw(string partPath, int? startRow = null, int? endRow = null, HashSet<string>? cols = null);
void RawSet(string partPath, string xpath, string action, string? xml);
/// <summary>
/// Create a new part (chart, header, footer, etc.) and return its relationship ID and accessible path.
/// </summary>
(string RelId, string PartPath) AddPart(string parentPartPath, string partType, Dictionary<string, string>? properties = null);
/// <summary>
/// Validate the document against OpenXML schema and return any errors.
/// </summary>
List<ValidationError> Validate();
/// <summary>
/// Extract the binary payload backing a node (ole/picture/media/embedded)
/// to <paramref name="destPath"/>. Returns <c>true</c> if the node has a
/// backing part and the bytes were written, <c>false</c> if the node has
/// no binary payload (e.g. it is a text paragraph or table cell).
/// <paramref name="contentType"/> receives the part's MIME type on success;
/// <paramref name="byteCount"/> receives the number of bytes written.
/// </summary>
bool TryExtractBinary(string path, string destPath, out string? contentType, out long byteCount);
/// <summary>
/// Flush the in-memory OOXML package to disk without ending the session.
/// Only meaningful when the handler was opened with editable=true.
/// </summary>
void Save();
}
public record ValidationError(string ErrorType, string Description, string? Path, string? Part);
+367
View File
@@ -0,0 +1,367 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using DocumentFormat.OpenXml.Packaging;
namespace OfficeCli.Core;
/// <summary>
/// Resolves image sources from file paths, data URIs, or HTTP(S) URLs into a stream and content type.
/// Supports:
/// - Local file path: "/tmp/logo.png", "C:\images\photo.jpg"
/// - Data URI: "data:image/png;base64,iVBOR..."
/// - HTTP(S) URL: "https://example.com/image.png"
///
/// Returns a content type string compatible with OpenXmlPart.AddImagePart() (e.g. ImagePartType.Png).
/// </summary>
internal static class ImageSource
{
/// <summary>
/// Resolve an image source string into a stream and content type string.
/// Caller is responsible for disposing the returned stream.
/// The returned contentType can be passed directly to AddImagePart().
/// </summary>
public static (Stream Stream, PartTypeInfo ContentType) Resolve(string source)
{
if (string.IsNullOrWhiteSpace(source))
throw new ArgumentException("Image source cannot be empty");
// Data URI: data:image/png;base64,iVBOR...
if (source.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
return ResolveDataUri(source);
// HTTP(S) URL
if (source.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
source.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return ResolveUrl(source);
// Local file path
return ResolveFile(source);
}
/// <summary>
/// Determine content type string from a file extension (with or without dot).
/// Returns a value usable with AddImagePart().
/// </summary>
public static PartTypeInfo ExtensionToContentType(string extension)
{
var ext = extension.TrimStart('.').ToLowerInvariant();
return ext switch
{
"png" => ImagePartType.Png,
"jpg" or "jpeg" => ImagePartType.Jpeg,
"gif" => ImagePartType.Gif,
"bmp" => ImagePartType.Bmp,
"tif" or "tiff" => ImagePartType.Tiff,
"emf" => ImagePartType.Emf,
"wmf" => ImagePartType.Wmf,
"svg" => ImagePartType.Svg,
_ => throw new ArgumentException($"Unsupported image format: .{ext}. Supported: png, jpg, gif, bmp, tiff, emf, wmf, svg")
};
}
private static (Stream, PartTypeInfo) ResolveFile(string path)
{
if (!File.Exists(path))
throw new FileNotFoundException($"Image file not found: {path}");
var contentType = ExtensionToContentType(Path.GetExtension(path));
var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant();
// Magic-byte validation for raster formats. SVG (XML) / EMF / WMF are
// intentionally skipped: SVG has no fixed magic, EMF/WMF have weaker
// headers and TrySniffContentType doesn't cover them. Only validate
// formats whose first 4 bytes are stable (png/jpg/gif/bmp/tiff).
var rasterExts = new[] { "png", "jpg", "jpeg", "gif", "bmp", "tif", "tiff" };
if (rasterExts.Contains(ext))
{
var bytes = File.ReadAllBytes(path);
if (TrySniffContentType(bytes, out var sniffed))
{
if (!IsCompatible(sniffed, contentType))
throw new ArgumentException(
$"Image file '{path}' has extension .{ext} but magic bytes indicate {ContentTypeName(sniffed)}. " +
"Rename or convert the file.");
}
else
{
throw new ArgumentException(
$"Image file '{path}' does not appear to be a valid {ext} file (magic bytes mismatch).");
}
return (new MemoryStream(bytes, writable: false), contentType);
}
return (File.OpenRead(path), contentType);
}
private static bool IsCompatible(PartTypeInfo sniffed, PartTypeInfo declared)
{
if (sniffed == declared) return true;
// jpg/jpeg are the same PartTypeInfo so this collapses naturally.
return false;
}
private static string ContentTypeName(PartTypeInfo type)
{
if (type == ImagePartType.Png) return "PNG";
if (type == ImagePartType.Jpeg) return "JPEG";
if (type == ImagePartType.Gif) return "GIF";
if (type == ImagePartType.Bmp) return "BMP";
if (type == ImagePartType.Tiff) return "TIFF";
return type.ContentType ?? "unknown";
}
private static (Stream, PartTypeInfo) ResolveDataUri(string dataUri)
{
// Format: data:[<mediatype>][;base64],<data>
var commaIdx = dataUri.IndexOf(',');
if (commaIdx < 0)
throw new ArgumentException("Invalid data URI: missing comma separator");
var header = dataUri[..commaIdx]; // e.g. "data:image/png;base64"
var data = dataUri[(commaIdx + 1)..];
if (!header.Contains("base64", StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("Only base64-encoded data URIs are supported");
// Extract MIME type
var mimeStart = header.IndexOf(':') + 1;
var mimeEnd = header.IndexOf(';');
var mime = mimeEnd > mimeStart ? header[mimeStart..mimeEnd] : header[mimeStart..];
var contentType = MimeToContentType(mime);
var bytes = Convert.FromBase64String(data);
return (new MemoryStream(bytes), contentType);
}
private static (Stream, PartTypeInfo) ResolveUrl(string url)
{
// SSRF guard lives in the shared SsrfGuard so image and file fetch can
// never diverge in policy. See SsrfGuard for the connect-time / redirect
// / DNS-rebinding rationale.
var handler = SsrfGuard.CreateGuardedHandler("image");
using var client = new HttpClient(handler, disposeHandler: true) { Timeout = TimeSpan.FromSeconds(30) };
client.DefaultRequestHeaders.Add("User-Agent", "OfficeCLI");
var response = client.GetAsync(url).GetAwaiter().GetResult();
response.EnsureSuccessStatusCode();
// Enforce the shared size cap whether or not the server sends
// Content-Length. ReadBounded lives in SsrfGuard so image and file
// fetch share one limit (see SsrfGuard.MaxRemoteBytes).
var declared = response.Content.Headers.ContentLength;
if (declared is > SsrfGuard.MaxRemoteBytes)
throw new ArgumentException($"Remote image exceeds {SsrfGuard.MaxRemoteBytes / (1024 * 1024)} MB limit.");
var bytes = SsrfGuard.ReadBounded(response.Content.ReadAsStream(), SsrfGuard.MaxRemoteBytes, url, "image");
var stream = new MemoryStream(bytes);
// Try content-type header first
var serverMime = response.Content.Headers.ContentType?.MediaType;
if (!string.IsNullOrEmpty(serverMime) && TryMimeToContentType(serverMime, out var ct))
return (stream, ct);
// Fallback: extract extension from URL path (strip query string)
var uri = new Uri(url);
var ext = Path.GetExtension(uri.AbsolutePath);
if (!string.IsNullOrEmpty(ext))
return (stream, ExtensionToContentType(ext));
// Last resort: sniff magic bytes
if (TrySniffContentType(bytes, out var sniffed))
return (stream, sniffed);
throw new ArgumentException($"Cannot determine image type from URL: {url}. Specify format via file extension or content-type header.");
}
private static PartTypeInfo MimeToContentType(string mime)
{
if (TryMimeToContentType(mime, out var ct)) return ct;
throw new ArgumentException($"Unsupported MIME type: {mime}. Supported: image/png, image/jpeg, image/gif, image/bmp, image/tiff, image/svg+xml");
}
private static bool TryMimeToContentType(string mime, out PartTypeInfo contentType)
{
contentType = mime.ToLowerInvariant() switch
{
"image/png" => ImagePartType.Png,
"image/jpeg" or "image/jpg" => ImagePartType.Jpeg,
"image/gif" => ImagePartType.Gif,
"image/bmp" => ImagePartType.Bmp,
"image/tiff" or "image/tif" => ImagePartType.Tiff,
"image/svg+xml" => ImagePartType.Svg,
"image/emf" or "image/x-emf" => ImagePartType.Emf,
"image/wmf" or "image/x-wmf" => ImagePartType.Wmf,
_ => default
};
return contentType != default;
}
private static bool TrySniffContentType(byte[] bytes, out PartTypeInfo contentType)
{
contentType = default;
if (bytes.Length < 4) return false;
// PNG: 89 50 4E 47
if (bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47)
{ contentType = ImagePartType.Png; return true; }
// JPEG: FF D8 FF
if (bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF)
{ contentType = ImagePartType.Jpeg; return true; }
// GIF: GIF8
if (bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38)
{ contentType = ImagePartType.Gif; return true; }
// BMP: BM
if (bytes[0] == 0x42 && bytes[1] == 0x4D)
{ contentType = ImagePartType.Bmp; return true; }
// TIFF little-endian: 49 49 2A 00 ("II" + magic 42)
if (bytes[0] == 0x49 && bytes[1] == 0x49 && bytes[2] == 0x2A && bytes[3] == 0x00)
{ contentType = ImagePartType.Tiff; return true; }
// TIFF big-endian: 4D 4D 00 2A ("MM" + magic 42)
if (bytes[0] == 0x4D && bytes[1] == 0x4D && bytes[2] == 0x00 && bytes[3] == 0x2A)
{ contentType = ImagePartType.Tiff; return true; }
return false;
}
/// <summary>
/// Try to read pixel (width, height) by parsing image file headers.
/// Cross-platform — pure byte parsing, no System.Drawing / GDI dependency.
/// Supports PNG, JPEG, GIF, BMP. Returns null for any unrecognized or
/// malformed header. The stream position is restored on return.
/// </summary>
public static (int Width, int Height)? TryGetDimensions(Stream stream)
{
if (stream is null || !stream.CanSeek || stream.Length < 24) return null;
var startPos = stream.Position;
try
{
stream.Position = 0;
var header = new byte[30];
var read = stream.Read(header, 0, header.Length);
if (read < 24) return null;
// PNG: signature 89 50 4E 47 0D 0A 1A 0A, IHDR width/height at
// big-endian offsets 16..19 and 20..23.
if (header[0] == 0x89 && header[1] == 0x50 && header[2] == 0x4E && header[3] == 0x47)
{
int w = ReadBE32(header, 16);
int h = ReadBE32(header, 20);
return (w > 0 && h > 0) ? (w, h) : null;
}
// BMP: signature 42 4D, width little-endian at offset 18, height at 22.
// Height may be negative for top-down bitmaps; take the absolute value.
if (header[0] == 0x42 && header[1] == 0x4D && read >= 26)
{
int w = ReadLE32(header, 18);
int h = ReadLE32(header, 22);
if (h < 0) h = -h;
return (w > 0 && h > 0) ? (w, h) : null;
}
// GIF: signature 47 49 46 38, logical screen width/height are
// little-endian uint16 at offsets 6 and 8.
if (header[0] == 0x47 && header[1] == 0x49 && header[2] == 0x46 && header[3] == 0x38)
{
int w = header[6] | (header[7] << 8);
int h = header[8] | (header[9] << 8);
return (w > 0 && h > 0) ? (w, h) : null;
}
// JPEG: signature FF D8 — walk markers to find a Start-of-Frame.
if (header[0] == 0xFF && header[1] == 0xD8)
return TryGetJpegDimensions(stream);
// SVG: XML text — sniff for <?xml or <svg in the header and
// delegate to the SVG parser. Handled after the binary
// signatures above so SVG files with stray leading whitespace
// don't get mis-sniffed as PNG/BMP/GIF/JPEG.
if (LooksLikeSvgHeader(header, read))
return SvgImageHelper.TryGetSvgDimensions(stream);
return null;
}
catch (IOException)
{
return null;
}
finally
{
try { stream.Position = startPos; } catch (IOException) { /* best effort */ }
}
}
private static bool LooksLikeSvgHeader(byte[] header, int read)
{
if (header is null || read < 4) return false;
int i = 0;
// UTF-8 BOM
if (read >= 3 && header[0] == 0xEF && header[1] == 0xBB && header[2] == 0xBF) i = 3;
while (i < read && (header[i] == ' ' || header[i] == '\t'
|| header[i] == '\r' || header[i] == '\n')) i++;
if (i >= read || header[i] != (byte)'<') return false;
var text = System.Text.Encoding.UTF8.GetString(header, i, read - i).ToLowerInvariant();
return text.StartsWith("<svg") || text.StartsWith("<?xml") || text.StartsWith("<!doctype svg");
}
private static int ReadBE32(byte[] buf, int offset) =>
(buf[offset] << 24) | (buf[offset + 1] << 16) | (buf[offset + 2] << 8) | buf[offset + 3];
private static int ReadLE32(byte[] buf, int offset) =>
buf[offset] | (buf[offset + 1] << 8) | (buf[offset + 2] << 16) | (buf[offset + 3] << 24);
private static (int Width, int Height)? TryGetJpegDimensions(Stream stream)
{
// Skip the SOI marker (FF D8) and walk segment markers looking for
// a Start-of-Frame (SOFn) marker, which holds the true pixel size.
stream.Position = 2;
var buf = new byte[7];
while (stream.Position < stream.Length - 2)
{
int b1 = stream.ReadByte();
if (b1 != 0xFF) return null;
int b2;
do
{
b2 = stream.ReadByte();
} while (b2 == 0xFF && stream.Position < stream.Length);
if (b2 < 0) return null;
// SOFn markers: C0..C3, C5..C7, C9..CB, CD..CF. These all carry
// the frame header (height then width, each big-endian uint16).
bool isSof = (b2 >= 0xC0 && b2 <= 0xC3)
|| (b2 >= 0xC5 && b2 <= 0xC7)
|| (b2 >= 0xC9 && b2 <= 0xCB)
|| (b2 >= 0xCD && b2 <= 0xCF);
if (isSof)
{
if (stream.Read(buf, 0, 7) < 7) return null;
int h = (buf[3] << 8) | buf[4];
int w = (buf[5] << 8) | buf[6];
return (w > 0 && h > 0) ? (w, h) : null;
}
// Start-of-Scan: image data begins, no more metadata.
if (b2 == 0xDA) return null;
// Any other segment: skip over its declared length.
if (stream.Read(buf, 0, 2) < 2) return null;
int len = (buf[0] << 8) | buf[1];
if (len < 2) return null;
stream.Position += len - 2;
}
return null;
}
}
+349
View File
@@ -0,0 +1,349 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics;
namespace OfficeCli.Core;
/// <summary>
/// Installs officecli binary, skills, and MCP (for tools without skill support).
/// Usage:
/// officecli install [target] — install binary + skills + fallback MCP
/// </summary>
internal static class Installer
{
private static readonly string BinDir = OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OfficeCli")
: Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin");
private static readonly string TargetPath = Path.Combine(BinDir,
OperatingSystem.IsWindows() ? "officecli.exe" : "officecli");
/// <summary>Canonical install location of the officecli binary
/// (<c>~/.local/bin/officecli</c> on Unix, <c>%LOCALAPPDATA%\OfficeCli</c>
/// on Windows). External registrations (MCP, etc.) should record this path
/// rather than <see cref="Environment.ProcessPath"/> so the command survives
/// upgrades — self-install overwrites this file in place.</summary>
internal static string InstalledBinaryPath => TargetPath;
/// <summary>
/// MCP targets and the skill aliases that overlap with them.
/// If any of the skill aliases were installed, skip MCP for that target.
/// </summary>
private static readonly (string McpTarget, string DetectDir, string[] SkillAliases)[] McpTargets =
[
("claude", ".claude", ["claude", "claude-code"]),
("cursor", ".cursor", ["cursor"]),
("vscode", ".vscode", []), // no skill equivalent
("lms", ".cache/lm-studio", []), // no skill equivalent
];
public static int Run(string[] args)
{
InstallBinary();
var target = args.Length >= 1 ? args[0] : "all";
// Skip the skill phase when the target is MCP-only (vscode, lms).
// SkillInstaller has no equivalent agent for these and would otherwise
// print a misleading 'Unknown target' to stderr before InstallMcpFallback
// succeeds. The skill/MCP target namespaces are deliberately allowed to
// diverge — McpTargets with empty SkillAliases is the source of truth
// for "no skill phase needed".
var isMcpOnly = McpTargets.Any(t =>
t.SkillAliases.Length == 0 &&
t.McpTarget.Equals(target, StringComparison.OrdinalIgnoreCase));
var skilledTools = isMcpOnly
? new HashSet<string>(StringComparer.OrdinalIgnoreCase)
: SkillInstaller.Install(target);
// Install MCP for tools that didn't get a skill
var mcpInstalled = InstallMcpFallback(skilledTools, target);
// Exit 1 when a specific target was named but neither skills nor MCP
// recognized it. 'all' (default) is always success because there's
// nothing to mistype. Without this, `officecli install bogus` would
// exit 0 after only printing 'Unknown target' to stderr — automation
// can't distinguish a typo from a successful install.
var isAll = target.Equals("all", StringComparison.OrdinalIgnoreCase);
if (!isAll && skilledTools.Count == 0 && !mcpInstalled)
return 1;
return 0;
}
private static bool InstallMcpFallback(HashSet<string> skilledTools, string target)
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var isAll = target.Equals("all", StringComparison.OrdinalIgnoreCase);
var anyInstalled = false;
foreach (var (mcpTarget, detectDir, skillAliases) in McpTargets)
{
// If targeting a specific tool, only process matching MCP target
if (!isAll && !mcpTarget.Equals(target, StringComparison.OrdinalIgnoreCase))
continue;
// Skip if skill was already installed for this tool
if (skillAliases.Any(a => skilledTools.Contains(a)))
continue;
// Only install if the tool's directory exists
if (Directory.Exists(Path.Combine(home, detectDir)))
{
if (McpInstaller.Install(mcpTarget))
anyInstalled = true;
}
}
return anyInstalled;
}
internal static bool InstallBinary(bool quiet = false)
{
var src = Environment.ProcessPath;
if (string.IsNullOrEmpty(src))
return false;
// Already at target location — record version and skip the copy
var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), pathComparison))
{
RecordInstalledVersion();
return false;
}
// Skip binary copy when managed by a package manager (Homebrew, etc.)
if (src.Contains("/Caskroom/") || src.Contains("/Cellar/"))
{
if (!quiet)
Console.WriteLine("Skipping binary install: managed by Homebrew.");
RecordInstalledVersion();
return false;
}
// Skip if not a self-contained published binary (e.g. running via dotnet run)
// Self-contained single-file binaries are typically >5MB; framework-dependent builds are <1MB
var srcInfo = new FileInfo(src);
if (srcInfo.Length < 5 * 1024 * 1024)
{
if (!quiet)
{
Console.WriteLine($"Skipping binary install: not a published self-contained binary.");
Console.WriteLine($" Run: dotnet publish -c Release -r <rid> --self-contained -p:PublishSingleFile=true");
}
return false;
}
Directory.CreateDirectory(BinDir);
File.Copy(src, TargetPath, overwrite: true);
// Preserve executable permission on Unix
if (!OperatingSystem.IsWindows())
{
try
{
File.SetUnixFileMode(TargetPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
}
catch { /* best effort */ }
}
RecordInstalledVersion();
if (quiet)
Console.Error.WriteLine($"note: officecli self-installed to {TargetPath}");
else
Console.WriteLine($"Installed binary to {TargetPath}");
EnsurePath(quiet);
return true;
}
private static void RecordInstalledVersion()
{
try
{
var current = UpdateChecker.GetCurrentVersionPublic();
if (string.IsNullOrEmpty(current)) return;
var config = UpdateChecker.LoadConfig();
if (config.InstalledBinaryVersion == current) return;
config.InstalledBinaryVersion = current;
UpdateChecker.SaveConfig(config);
}
catch { /* best effort */ }
}
/// <summary>
/// Auto-install hook called on every officecli invocation.
/// - Target missing → full install (binary + skills + MCP fallback).
/// - Target older than current → binary-only upgrade.
/// - Otherwise → no-op (cheap path: one File.Exists + one config read).
/// Never throws, never blocks the main command.
/// </summary>
internal static void MaybeAutoInstall(string[] args)
{
try
{
// Opt-out
if (Environment.GetEnvironmentVariable("OFFICECLI_NO_AUTO_INSTALL") == "1")
return;
// Only trigger on bare `officecli` invocation (exploratory / discovery call).
// Real work commands (view, set, add, create, ...) are left alone to keep
// zero side-effects and zero overhead on the hot path.
if (args.Length != 0)
return;
var src = Environment.ProcessPath;
if (string.IsNullOrEmpty(src)) return;
// Already running from target — nothing to do (RecordInstalledVersion is handled by explicit `install`)
var pathComparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
if (string.Equals(Path.GetFullPath(src), Path.GetFullPath(TargetPath), pathComparison))
return;
// Dev-build filter: framework-dependent / dotnet run binaries are <5MB
FileInfo srcInfo;
try { srcInfo = new FileInfo(src); }
catch { return; }
if (srcInfo.Length < 5 * 1024 * 1024) return;
var currentVer = UpdateChecker.GetCurrentVersionPublic();
if (string.IsNullOrEmpty(currentVer)) return;
if (!File.Exists(TargetPath))
{
// Fresh install — full Run() (binary + skills + MCP fallback)
Console.Error.WriteLine($"note: officecli not installed yet, running first-time install...");
Run([]);
return;
}
// Upgrade case — compare current vs config-recorded version
var config = UpdateChecker.LoadConfig();
var installedVer = config.InstalledBinaryVersion;
if (string.IsNullOrEmpty(installedVer))
{
// Config field missing (older install) — fall back to subprocess once.
installedVer = ReadVersionFromBinary(TargetPath);
if (!string.IsNullOrEmpty(installedVer))
{
config.InstalledBinaryVersion = installedVer;
try { UpdateChecker.SaveConfig(config); } catch { }
}
}
if (string.IsNullOrEmpty(installedVer)) return;
if (!UpdateChecker.IsNewerPublic(currentVer, installedVer)) return;
// Strict upgrade — binary only, leave skills/MCP alone
InstallBinary(quiet: true);
}
catch { /* never block the user's command */ }
}
private static string? ReadVersionFromBinary(string path)
{
try
{
var psi = new ProcessStartInfo
{
FileName = path,
Arguments = "--version",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var proc = Process.Start(psi);
if (proc == null) return null;
if (!proc.WaitForExit(2000))
{
try { proc.Kill(); } catch { }
return null;
}
var output = (proc.StandardOutput.ReadToEnd() + " " + proc.StandardError.ReadToEnd()).Trim();
// Match first x.y.z token
var match = System.Text.RegularExpressions.Regex.Match(output, @"\d+\.\d+\.\d+");
return match.Success ? match.Value : null;
}
catch { return null; }
}
private static bool IsInPath()
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
return pathEnv.Split(Path.PathSeparator).Any(p =>
{
try { return Path.GetFullPath(p).Equals(Path.GetFullPath(BinDir), StringComparison.OrdinalIgnoreCase); }
catch { return false; }
});
}
private static void EnsurePath(bool quiet = false)
{
if (IsInPath())
return;
var exportLine = $"export PATH=\"{BinDir}:$PATH\"";
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
// Determine shell profile to update
string profilePath;
if (OperatingSystem.IsWindows())
{
// Windows: add to user PATH via registry (same as install.ps1)
var currentPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User) ?? "";
if (!currentPath.Split(Path.PathSeparator).Contains(BinDir, StringComparer.OrdinalIgnoreCase))
{
var newPath = string.IsNullOrEmpty(currentPath) ? BinDir : $"{currentPath}{Path.PathSeparator}{BinDir}";
Environment.SetEnvironmentVariable("Path", newPath, EnvironmentVariableTarget.User);
if (!quiet)
{
Console.WriteLine($" Added {BinDir} to PATH.");
Console.WriteLine($" Restart your terminal to apply changes.");
}
}
return;
}
var shell = Environment.GetEnvironmentVariable("SHELL") ?? "";
if (shell.EndsWith("/zsh"))
profilePath = Path.Combine(home, ".zshrc");
else if (shell.EndsWith("/bash"))
profilePath = Path.Combine(home, ".bashrc");
else if (shell.EndsWith("/fish"))
{
// fish uses a different syntax
var fishConfig = Path.Combine(home, ".config", "fish", "config.fish");
var fishLine = $"fish_add_path {BinDir}";
AppendIfMissing(fishConfig, fishLine, BinDir);
return;
}
else
{
// Unknown shell — try .profile as fallback
profilePath = Path.Combine(home, ".profile");
}
AppendIfMissing(profilePath, exportLine, BinDir);
}
private static void AppendIfMissing(string profilePath, string line, string marker)
{
// Check if already present in the file
if (File.Exists(profilePath))
{
var content = File.ReadAllText(profilePath);
if (content.Contains(marker))
return;
}
Directory.CreateDirectory(Path.GetDirectoryName(profilePath)!);
File.AppendAllText(profilePath, $"\n# Added by officecli\n{line}\n");
Console.WriteLine($" Added {marker} to PATH in {profilePath}");
Console.WriteLine($" Run: source {profilePath} (or open a new terminal)");
}
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Central catalogue of <c>view issues --type</c> accepted values. Single
/// source of truth so the CLI front-end (CommandBuilder.View) and the
/// resident server (ResidentServer.ExecuteView) reject typos identically
/// and the cross-handler protocol documentation cannot drift from what the
/// validator actually accepts.
/// </summary>
public static class IssueSubtypes
{
public const string FormulaNotEvaluated = "formula_not_evaluated";
public const string FormulaCacheStale = "formula_cache_stale";
public const string FormulaRefMissingSheet = "formula_ref_missing_sheet";
public const string FormulaEvalError = "formula_eval_error";
public const string FieldNotEvaluated = "field_not_evaluated";
public const string FieldCacheStale = "field_cache_stale";
public const string SlideFieldNotEvaluated = "slide_field_not_evaluated";
public const string ChartSeriesRefMissingSheet = "chart_series_ref_missing_sheet";
public const string ChartCacheStale = "chart_cache_stale";
public const string DefinedNameBroken = "definedname_broken";
public const string DefinedNameTargetMissing = "definedname_target_missing";
public const string BrokenPartRef = "broken_part_ref";
/// <summary>pptx-only: notesSlide raw-set passthrough references an
/// rId (<c>r:embed</c> / <c>r:link</c>) the dump pass cannot reproduce
/// on the replay target (e.g. a non-image rel attached to a NotesSlidePart
/// — embedded media, OLE, etc.). The raw-set still emits, but PowerPoint
/// shows the referenced object as a broken placeholder on open. Emitted
/// as an UnsupportedWarning during dump; the surfaced site is the slide
/// owning the notes (<c>/slide[N]/notes</c>).</summary>
public const string NotesUnresolvedRid = "notes_unresolved_rid";
/// <summary>pptx-only: a shape with its own opaque dark solid fill carries
/// opaque dark text (fill brightness &lt; 30%, run brightness &lt; 80%) — the
/// text is unreadable when projected. Declared-model only: the shape's
/// explicit fill is compared against its explicit run colors, so the
/// backdrop is unambiguous (no z-order guesswork). Scheme/inherited colors,
/// translucent runs, and colors carrying lumMod/shade transforms are
/// skipped to keep false positives near zero. Format bucket, Warning.</summary>
public const string LowContrast = "low_contrast";
/// <summary>Broad IssueType bucket names — the canonical surface shown
/// in error messages and help. Single-letter aliases (<see cref="BucketAliases"/>)
/// are accepted by Validate but kept out of the user-facing list so the
/// canonical-vs-alias distinction is visible.</summary>
public static readonly string[] BucketNames =
new[] { "format", "content", "structure" };
/// <summary>Single-letter aliases accepted in addition to the canonical
/// bucket names. Kept separate from <see cref="BucketNames"/> so error
/// listings don't expose them as first-class values.</summary>
public static readonly string[] BucketAliases =
new[] { "f", "c", "s" };
/// <summary>Combined accepted bucket inputs (canonical + aliases).</summary>
public static readonly string[] ValidBuckets =
BucketNames.Concat(BucketAliases).ToArray();
/// <summary>Every subtype the <c>view issues</c> filter accepts by name.</summary>
public static readonly string[] ValidSubtypes = new[]
{
FormulaNotEvaluated, FormulaCacheStale, FormulaRefMissingSheet, FormulaEvalError,
FieldNotEvaluated, FieldCacheStale,
SlideFieldNotEvaluated, NotesUnresolvedRid, LowContrast,
ChartSeriesRefMissingSheet, ChartCacheStale,
DefinedNameBroken, DefinedNameTargetMissing,
BrokenPartRef,
};
/// <summary>Subtypes that are scanned by default and surface under
/// <c>--type content</c>. Opt-in subtypes (currently only
/// <see cref="ChartCacheStale"/>) require an exact-name request.</summary>
public static readonly string[] OptInSubtypes = new[] { ChartCacheStale };
/// <summary>One-line summary suitable for the CLI <c>--type</c> help
/// text. Generated from <see cref="ValidSubtypes"/> so the help cannot
/// drift from the validator.</summary>
public static string TypeHelpDescription()
{
var defaults = ValidSubtypes.Where(s => !OptInSubtypes.Contains(s));
return "Issue type filter. Broad buckets: "
+ string.Join(", ", BucketNames)
+ " (alias " + string.Join(", ", BucketAliases) + "). "
+ "Subtypes (Content bucket, returned by default and via --type content): "
+ string.Join(", ", defaults) + ". "
+ "Opt-in only (request by exact name; not included in --type content): "
+ string.Join(", ", OptInSubtypes) + ". "
+ "Subtypes are format-specific — formula_* / chart_* / definedname_* apply to xlsx, "
+ "field_* to docx, slide_field_* / notes_unresolved_rid / broken_part_ref / low_contrast to pptx; requesting a subtype that does not apply to "
+ "the queried file returns count=0 (not an error). "
+ "All values are case-insensitive and surrounding whitespace is trimmed.";
}
/// <summary>
/// Validate a user-supplied <c>--type</c> argument and return the
/// canonicalised form. Null, empty, and whitespace-only inputs are
/// normalised to null (treated as "no filter"). Surrounding whitespace
/// is trimmed so values copied from shells with extra spaces still
/// match. Recognised buckets and subtypes (case-insensitive) pass
/// through unchanged. Anything else raises <see cref="CliException"/>
/// with the full valid list — turning silent typos into a clear
/// failure on both the CLI front-end and the resident-server fan-out.
/// </summary>
public static string? Validate(string? issueType)
{
if (string.IsNullOrWhiteSpace(issueType)) return null;
var trimmed = issueType.Trim();
var canonical = trimmed.ToLowerInvariant();
foreach (var v in ValidBuckets) if (v == canonical) return trimmed;
foreach (var v in ValidSubtypes) if (v == canonical) return trimmed;
var all = ValidBuckets.Concat(ValidSubtypes).ToArray();
throw new CliException(
$"Invalid --type value: '{issueType}'. Valid buckets: {string.Join(", ", BucketNames)} (alias {string.Join(", ", BucketAliases)}). Valid subtypes: {string.Join(", ", ValidSubtypes)}.")
{ Code = "invalid_issue_type", ValidValues = all };
}
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Single source of truth for KaTeX asset URLs in generated HTML.
/// Mirrors the mermaid sourcing policy (see <c>MermaidImageRenderer</c>):
/// own mirror first (no third-party dependency at steady state, reachable in
/// networks where jsdelivr is not), public CDN as the fallback. The mirror
/// hosts the full dist subtree (js + css + fonts/) under one immutable
/// versioned prefix, so the css's relative <c>url(fonts/…)</c> references
/// resolve against whichever origin actually served it.
///
/// Unlike mermaid there is no local file cache: mermaid.js is executed by
/// the CLI's own headless browser against a throwaway file, while these URLs
/// are baked into preview HTML that users keep and open elsewhere — a
/// <c>file://~/.officecli/…</c> reference would break the moment the HTML
/// leaves the machine. Load robustness comes from the mirror→CDN onerror
/// chain plus the screenshot path's --virtual-time-budget/--timeout caps.
/// </summary>
internal static class KatexAssets
{
public const string Version = "0.16.11";
private const string MirrorBase = "https://d.officecli.ai/assets/katex-" + Version;
private const string CdnBase = "https://cdn.jsdelivr.net/npm/katex@" + Version + "/dist";
public static string CssUrl => MirrorBase + "/katex.min.css";
public static string JsUrl => MirrorBase + "/katex.min.js";
public static string CdnCssUrl => CdnBase + "/katex.min.css";
public static string CdnJsUrl => CdnBase + "/katex.min.js";
/// <summary>
/// onerror body for the stylesheet &lt;link&gt;: first failure retries the
/// public CDN, second failure removes the tag (KaTeX js falls back to the
/// caller-provided plain-text rendering, so a missing css is cosmetic).
/// </summary>
public static string CssOnErrorJs =>
$"if(!this.dataset.f){{this.dataset.f=1;this.href='{CdnCssUrl}'}}else{{this.remove()}}";
/// <summary>
/// onerror body for the &lt;script&gt; tag: first failure injects a CDN
/// copy whose own failure runs <paramref name="finalFallbackJs"/> (each
/// call site keeps its existing degraded-rendering behavior).
/// </summary>
public static string JsOnErrorJs(string finalFallbackJs) =>
"var s=document.createElement('script');s.src='" + CdnJsUrl + "';"
+ "s.onerror=function(){" + finalFallbackJs + "};document.head.appendChild(s)";
}
+210
View File
@@ -0,0 +1,210 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Locale → default font mapping for fresh blank documents. Mirrors the
/// data-driven approach used by mature producers (VCL.xcu): given a locale tag, pick
/// reasonable defaults for the Latin / EastAsian / ComplexScript font slots.
///
/// We deliberately keep this small (one line per locale family) rather than
/// trying to model every Office localization. When no locale is supplied,
/// returning all-empty values lets the host application substitute its own
/// UI-locale defaults — the behaviour BlankDocCreator already had after
/// we removed the "宋体" hardcode.
///
/// Font names are chosen for cross-platform availability (typefaces commonly
/// shipped on Windows and macOS, plus Apple Sans equivalents).
/// </summary>
public static class LocaleFontRegistry
{
/// <summary>
/// Resolve a locale tag (e.g. "zh-CN", "ja", "ar-SA") to a per-script
/// font triple. Returns (null, null, null) when no locale is supplied
/// or the tag is unknown — callers should treat that as "leave the
/// docDefaults blank, let the host application decide".
/// </summary>
public static (string? Latin, string? EastAsia, string? ComplexScript) Resolve(string? locale)
{
if (string.IsNullOrWhiteSpace(locale)) return (null, null, null);
// Match on language-only first; full tag lookups (e.g. zh-Hant) are
// routed through the language-only entry unless a region-specific
// variant exists.
var lower = locale.Replace('_', '-').ToLowerInvariant();
var lang = lower.Split('-')[0];
// Fully-tagged regional variants take precedence.
switch (lower)
{
case "zh-tw" or "zh-hk" or "zh-mo" or "zh-hant":
return ("Times New Roman", "新細明體", null);
case "zh-cn" or "zh-sg" or "zh-hans":
return ("Times New Roman", "等线", null);
}
// Language-only fall-throughs.
return lang switch
{
"zh" => ("Times New Roman", "等线", null),
"ja" => ("Times New Roman", "游明朝", null),
"ko" => ("Times New Roman", "맑은 고딕", null),
"ar" => ("Times New Roman", null, "Arabic Typesetting"),
"he" => ("Times New Roman", null, "Times New Roman"),
"th" => ("Times New Roman", null, "Tahoma"),
"fa" => ("Times New Roman", null, "B Nazanin"),
"ur" => ("Times New Roman", null, "Jameel Noori Nastaleeq"),
"hi" => ("Times New Roman", null, "Mangal"),
"en" or "fr" or "de" or "es" or "it" or "pt" or "nl" or "ru" or "pl"
=> ("Times New Roman", null, null),
_ => (null, null, null)
};
}
/// <summary>
/// OS user culture captured once at process startup, before the rest of
/// the cli forces the thread-current culture to Invariant for
/// deterministic OOXML / JSON / CSS output (see Program.cs). Read by
/// <see cref="ResolveEffectiveLocale"/> to recover the user's actual
/// language even though <c>CultureInfo.CurrentCulture</c> reports
/// Invariant from inside command handlers. Set by Program.cs and
/// treated as immutable from then on. Tests assign directly to
/// override.
/// </summary>
public static string? OsLocaleSnapshot { get; set; }
/// <summary>
/// Pick the effective locale for a newly-created document: explicit
/// `--locale` wins when supplied; otherwise fall back to the OS user
/// culture captured at startup so Arabic/Hebrew/Chinese/… users get a
/// doc shaped for their language without having to repeat the locale
/// on every invocation.
///
/// The startup snapshot honors CFLocale on macOS, $LANG / $LC_ALL on
/// Linux, and the OS user UI culture on Windows. In CI / Docker
/// images without locale config the runtime reports InvariantCulture
/// (empty Name), and bare `LANG=C` / `LANG=POSIX` map to the same —
/// treat all three as "no locale" so AI agents and pipelines don't
/// accidentally bake the build machine's culture into output docs.
/// </summary>
public static string? ResolveEffectiveLocale(string? explicitLocale)
{
if (!string.IsNullOrWhiteSpace(explicitLocale)) return explicitLocale;
var name = OsLocaleSnapshot;
if (string.IsNullOrEmpty(name)) return null;
if (name.Equals("C", StringComparison.OrdinalIgnoreCase)) return null;
if (name.Equals("POSIX", StringComparison.OrdinalIgnoreCase)) return null;
// Latin-script Western locales (en/fr/de/es/it/pt/nl/ru/pl/…) carry
// no locale-specific instruction — the doc would get the same
// Calibri / LTR baseline either way. Skip them so a default-shell
// English/French/… user keeps the modern Calibri baseline rather
// than the older Times New Roman we'd otherwise bake from
// <see cref="Resolve"/>. Explicit `--locale en-US` still goes
// through (it expresses intent — "I want this Latin font slot
// pinned"). This is the auto-detect-only filter.
var lang = name.Replace('_', '-').ToLowerInvariant().Split('-')[0];
if (lang is "en" or "fr" or "de" or "es" or "it" or "pt" or "nl" or "ru" or "pl")
return null;
return name;
}
/// <summary>
/// Locale-implied reading direction. RTL when the locale's primary
/// script flows right-to-left: Arabic, Hebrew, Yiddish, Urdu, Persian
/// (Farsi), Kashmiri, Sindhi, Uighur, Pashto, N'Ko, Dhivehi, Syriac,
/// and Kurdish written in Arabic script. Used by BlankDocCreator to
/// stamp <w:bidi/> defaults on sectPr and pPrDefault so users with
/// `--locale ar-SA` (etc.) don't have to set direction=rtl on every
/// paragraph they add.
/// </summary>
public static bool IsRightToLeft(string? locale)
{
if (string.IsNullOrWhiteSpace(locale)) return false;
var lang = locale.Replace('_', '-').ToLowerInvariant().Split('-')[0];
return lang switch
{
"ar" // Arabic
or "he" // Hebrew
or "iw" // Hebrew (legacy ISO 639-1)
or "yi" // Yiddish
or "ji" // Yiddish (legacy)
or "ur" // Urdu
or "fa" // Persian / Farsi
or "ps" // Pashto
or "sd" // Sindhi
or "ks" // Kashmiri
or "ug" // Uighur
or "ku" // Kurdish (Arabic-script variants — Sorani most commonly)
or "ckb" // Central Kurdish (Sorani)
or "dv" // Dhivehi / Maldivian
or "syr" // Syriac
or "nqo" // N'Ko
=> true,
_ => false
};
}
/// <summary>
/// Returns a CSS font-family fallback fragment for the locale's CJK script,
/// used by HTML/SVG renderers when the document's declared font isn't
/// installed on the rendering machine.
///
/// The returned fragment is comma-separated, individually quoted, NOT
/// prefixed with a comma — callers concatenate as needed. Empty string
/// for unknown/unspecified locales: callers should fall through to a
/// neutral generic family (e.g. <c>sans-serif</c>) so the rendering OS
/// picks a reasonable default rather than forcing one script's glyphs.
/// </summary>
public static string GetCjkCssFallback(string? locale)
{
if (string.IsNullOrWhiteSpace(locale)) return "";
var lang = locale.Replace('_', '-').ToLowerInvariant().Split('-')[0];
return lang switch
{
"zh" => "'PingFang SC', 'Microsoft YaHei', 'Noto Sans CJK SC', 'Hiragino Sans GB', 'Songti SC', 'STSong'",
"ja" => "'Hiragino Sans', 'Hiragino Mincho ProN', 'Yu Gothic', 'Yu Mincho', 'Noto Sans CJK JP', 'MS Gothic'",
"ko" => "'Apple SD Gothic Neo', 'Malgun Gothic', 'Noto Sans CJK KR', 'Batang'",
_ => ""
};
}
/// <summary>
/// Heuristic: detect a CJK locale tag ("zh" / "ja" / "ko") from a font
/// typeface name. Returns null when the name carries no strong script
/// signal. Used by renderers to pick the right fallback chain when the
/// document doesn't declare an explicit eastAsia language tag.
///
/// Order matters: Japanese is checked before Chinese because some JP
/// font names contain hanzi that overlap with Chinese keywords.
/// </summary>
public static string? DetectLocaleFromCjkFontName(string? font)
{
if (string.IsNullOrEmpty(font)) return null;
var lower = font.ToLowerInvariant();
if (lower.Contains("明朝") || lower.Contains("mincho")
|| lower.Contains("ゴシック") || lower.Contains("hiragino")
|| lower.Contains("yu mincho") || lower.Contains("yu gothic")
|| lower.Contains("ms mincho") || lower.Contains("ms gothic")
|| lower.Contains("meiryo") || lower.Contains("游明朝")
|| lower.Contains("游ゴシック"))
return "ja";
if (lower.Contains("바탕") || lower.Contains("굴림") || lower.Contains("돋움")
|| lower.Contains("맑은") || lower == "batang" || lower == "batangche"
|| lower == "gulim" || lower == "dotum" || lower.Contains("malgun")
|| lower.Contains("nanum") || lower.Contains("apple sd gothic"))
return "ko";
if (lower.Contains("宋") || lower.Contains("song") || lower.Contains("simsun")
|| lower.Contains("黑") || lower.Contains("hei") || lower.Contains("simhei")
|| lower.Contains("楷") || lower.Contains("kai") || lower.Contains("仿宋")
|| lower.Contains("fangsong") || lower.Contains("pingfang")
|| lower.Contains("yahei") || lower.Contains("等线") || lower.Contains("华文")
|| lower.Contains("方正") || lower.Contains("微软雅黑"))
return "zh";
return null;
}
}
+158
View File
@@ -0,0 +1,158 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Detects when the user's path argument was rewritten by Git Bash / MSYS2 /
/// Cygwin's POSIX-to-Windows path conversion before reaching the CLI. The
/// shell turns a leading '/' into its install root (e.g. '/' becomes
/// 'C:/Program Files/Git/'), which then fails downstream path validation.
/// </summary>
internal static class MsysPathHint
{
// Canonical prefix that opens every hint we emit. AugmentMessage uses it
// as the idempotency sentinel — keeping prefix and sentinel as ONE constant
// means a future copy-edit can't desync them without the compiler noticing.
private const string HintPrefix = "Git Bash rewrote";
// Mangled paths look like: a drive letter, forward slashes, optional
// trailing slash. e.g. "C:/Program Files/Git/", "D:/git/git/body".
// The drive letter is matched case-insensitively — the conversion emits an
// upper-case drive ("C:/...") in practice.
private static readonly Regex MangledShape = new(
@"^[A-Za-z]:/", RegexOptions.Compiled);
// Scan for mangled-shape tokens embedded in a longer error message.
// The token stops at whitespace, quotes, brackets, or sentence punctuation
// so we don't slurp trailing words.
private static readonly Regex MangledTokenInMessage = new(
@"[A-Za-z]:/[^\s'""<>\)\]]+", RegexOptions.Compiled);
// Marker files that identify an install as a real MSYS/Git/Cygwin root.
// Each entry is a relative path from the candidate root directory.
private static readonly string[] InstallMarkers =
{
"usr/bin/msys-2.0.dll", // MSYS2 / Git Bash
"git-bash.exe", // Git for Windows install root
"cygwin1.dll", // Cygwin install root
};
/// <summary>
/// When <paramref name="arg"/> is a shell-rewritten leading-'/' path that
/// landed inside a real MSYS / Git Bash / Cygwin install root, return the
/// path the user originally typed (e.g. "C:/Program Files/Git/body" becomes
/// "/body", "C:/Program Files/Git/" becomes "/"). Otherwise return the value
/// unchanged. Callers wrap any positional DOM-path argument with this so a
/// shell-mangled "/body" is recovered before path resolution, while every
/// non-mangled input (real Windows file paths, selectors, "selected") passes
/// through untouched — recovery that can't confirm a real install root just
/// falls back to the original value.
/// </summary>
public static string? Restore(string? arg)
{
if (string.IsNullOrEmpty(arg)) return arg;
return TryRecoverOriginalPath(arg) ?? arg;
}
/// <summary>
/// Return a hint string when <paramref name="offendingPath"/> looks like a
/// shell-rewritten argument that fell on disk inside a real MSYS / Git Bash
/// / Cygwin install. Returns null when nothing matches — call site should
/// then emit its normal error message unchanged.
/// </summary>
public static string? TryDescribeRewrite(string? offendingPath)
{
var original = TryRecoverOriginalPath(offendingPath);
if (original == null) return null;
var doubled = "/" + original;
return $"{HintPrefix} '{original}' before officecli received it. " +
$"Use '{doubled}' or set MSYS_NO_PATHCONV=1.";
}
/// <summary>
/// Shared core: if <paramref name="offendingPath"/> has the mangled shape
/// and resolves to a path inside a real MSYS / Git Bash / Cygwin install
/// root, return the path the user most likely typed (always '/'-prefixed).
/// Returns null when nothing matches.
/// </summary>
private static string? TryRecoverOriginalPath(string? offendingPath)
{
if (string.IsNullOrEmpty(offendingPath)) return null;
if (!OperatingSystem.IsWindows()) return null;
if (!MangledShape.IsMatch(offendingPath)) return null;
// Walk parent directories upward to find an install root that contains
// the offending path. The portion after the install root is what the
// user most likely typed.
var probe = offendingPath.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar);
if (probe.Length == 0) return null;
while (true)
{
if (LooksLikeShellInstallRoot(probe))
{
var probeForward = probe.Replace(Path.DirectorySeparatorChar, '/');
var tail = offendingPath.TrimEnd('/');
if (tail.Length <= probeForward.Length ||
!tail.StartsWith(probeForward, StringComparison.OrdinalIgnoreCase))
{
return "/";
}
var original = tail.Substring(probeForward.Length);
if (!original.StartsWith('/')) original = "/" + original;
return original;
}
var parent = Path.GetDirectoryName(probe);
if (string.IsNullOrEmpty(parent) || parent == probe) return null;
probe = parent;
}
}
/// <summary>
/// Single chokepoint used at the error-formatter layer: scan an arbitrary
/// error message for any embedded MSYS-mangled path token, probe it, and
/// append a hint if one matches. Idempotent — checked via the shared
/// <see cref="HintPrefix"/> sentinel so a second pass over an already-
/// augmented message returns it unchanged.
/// </summary>
public static string AugmentMessage(string? message)
{
if (string.IsNullOrEmpty(message)) return message ?? string.Empty;
if (!OperatingSystem.IsWindows()) return message;
if (message.Contains(HintPrefix, StringComparison.Ordinal)) return message;
foreach (Match m in MangledTokenInMessage.Matches(message))
{
var hint = TryDescribeRewrite(m.Value);
if (hint != null)
{
// Some upstream errors ("Path not found: <path>") don't end in
// sentence punctuation. Add a period so the appended hint
// reads as a separate sentence.
var sep = message[^1] is '.' or '!' or '?' ? " " : ". ";
return $"{message}{sep}{hint}";
}
}
return message;
}
/// <summary>
/// Returns true when <paramref name="dir"/> exists on disk and contains a
/// known MSYS / Git for Windows / Cygwin marker file.
/// </summary>
private static bool LooksLikeShellInstallRoot(string dir)
{
if (!Directory.Exists(dir)) return false;
foreach (var marker in InstallMarkers)
{
var rel = marker.Replace('/', Path.DirectorySeparatorChar);
if (File.Exists(Path.Combine(dir, rel))) return true;
}
return false;
}
}
@@ -0,0 +1,58 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Agent-safety guard for the MUTATING CLI/agent verbs (set, remove): reject a
/// bare, unscoped selector. A bare type selector — `cell`, `run`, `shape`,
/// `cell[value>5]`, `run[bold=true]` — matches across the ENTIRE document with
/// no scope, so one mistaken `set "cell"` rewrites every cell in every sheet and
/// `remove "run"` deletes every run. A mutation must name WHERE it applies:
///
/// • a `/`-scoped path — `/Sheet1/cell[...]`, `/slide[1]/shape[...]`, `/body/p[1]/r[...]`
/// • Excel `Sheet!Ref` — `Sheet1!A1`, `Sheet1!A1:B5`, `Sheet1!row[Amount>5000]`
///
/// `query` is intentionally NOT guarded: it is read-only and the bare type
/// selector is its primary, day-one (v1.0.0) discovery form. The guard lives at
/// the agent-facing layer (CLI / MCP / resident / batch) only — the handler
/// `Set`/`Remove` API stays permissive for internal recursion and programmatic
/// callers.
/// </summary>
public static class MutationSelectorGuard
{
// Excel `Sheet!Ref` notation: a sheet name (no '/', '[' or ']') then '!'.
// The char class stops at the first '[', so a bare filter whose VALUE happens
// to contain '!' (e.g. `cell[value=foo!]`) does not match — only a real
// sheet-separator '!' before any bracket counts as scoped.
private static readonly Regex ExcelNotation = new(@"^[^/\[\]]+!", RegexOptions.Compiled);
/// <summary>
/// Throw a CliException when <paramref name="path"/> is a bare unscoped
/// selector on a mutating verb. No-op for `/`-scoped paths, Excel `Sheet!Ref`
/// notation, and null/empty (handled downstream).
/// </summary>
public static void EnsureScoped(string? path, string verb)
{
if (string.IsNullOrEmpty(path)) return;
if (path.StartsWith("/")) return;
if (ExcelNotation.IsMatch(path)) return;
// A slash path that lost its leading slash ("Sheet1/row[...]") IS
// scoped — query already restores the slash and resolves it (see
// ExcelHandler.QueryDispatch); rejecting it here as "would match
// across the whole document" was both inconsistent and untrue. A '/'
// inside a predicate value (row[url~=a/b]) does not count.
if (SelectorCommaSplit.ContainsTopLevelChar(path, '/')) return;
throw new CliException(
$"Bare selector '{path}' is not allowed for '{verb}' — it would match across the whole document.")
{
Code = "bare_selector_rejected",
Suggestion =
$"Scope the {verb} to a path: '/Sheet1/{path}' / '/slide[1]/{path}' / '/body/p[1]/{path}', " +
"or use Excel notation 'Sheet1!A1'. Bare selectors stay available on read-only 'query'.",
};
}
}
+250
View File
@@ -0,0 +1,250 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Reflection;
using System.Xml;
using System.Xml.Linq;
using DocumentFormat.OpenXml.Packaging;
using AP = DocumentFormat.OpenXml.ExtendedProperties;
using CP = DocumentFormat.OpenXml.CustomProperties;
using VT = DocumentFormat.OpenXml.VariantTypes;
namespace OfficeCli.Core;
/// <summary>
/// Stamps OOXML packages with OfficeCLI identification (app.xml + core.xml).
/// </summary>
internal static class OfficeCliMetadata
{
public const string ProductName = "OfficeCLI";
// Application string follows the convention "<Product>/<Version>"
// so the version is visible everywhere Application is surfaced (Windows
// Word's Advanced Properties → Statistics, audit tools, file inspectors).
// We deliberately omit ap:AppVersion: its OOXML "X.YYYY" format would
// require lossy mangling of semver, and no major Office UI surfaces it.
private static readonly string _appName = $"{ProductName}/{ResolveVersion()}";
/// <summary>String written to <c>ap:Application</c>, e.g. "OfficeCLI/1.0.58".</summary>
public static string AppName => _appName;
/// <summary>Bare product name, written to <c>dc:creator</c> and <c>cp:lastModifiedBy</c>.</summary>
public static string CreatorName => ProductName;
private static string ResolveVersion()
{
var asm = typeof(OfficeCliMetadata).Assembly;
var info = asm.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? asm.GetName().Version?.ToString()
?? "0.0.0";
var plus = info.IndexOf('+');
return plus > 0 ? info[..plus] : info;
}
private const string CpNs = "http://schemas.openxmlformats.org/package/2006/metadata/core-properties";
private const string DcNs = "http://purl.org/dc/elements/1.1/";
private const string DctermsNs = "http://purl.org/dc/terms/";
private const string XsiNs = "http://www.w3.org/2001/XMLSchema-instance";
private static CoreFilePropertiesPart? GetOrCreateCorePart(OpenXmlPackage doc) => doc switch
{
WordprocessingDocument w => w.CoreFilePropertiesPart ?? w.AddCoreFilePropertiesPart(),
SpreadsheetDocument s => s.CoreFilePropertiesPart ?? s.AddCoreFilePropertiesPart(),
PresentationDocument p => p.CoreFilePropertiesPart ?? p.AddCoreFilePropertiesPart(),
_ => null
};
/// <summary>
/// Marshal core properties directly to the CoreFilePropertiesPart stream.
/// We bypass <see cref="OpenXmlPackage.PackageProperties"/> on purpose: that
/// path delegates to <c>System.IO.Packaging.Package.PackageProperties</c>,
/// which on .NET stores props in a non-canonical
/// <c>/package/services/metadata/core-properties/&lt;guid&gt;.psmdcp</c> blob
/// instead of the standard <c>/docProps/core.xml</c> Office writes.
///
/// Read-modify-write semantics: every existing element (with its
/// attributes) is preserved verbatim — including non-standard fields
/// other producers (Pages / Keynote / WPS) occasionally add — and only the
/// four OfficeCLI-relevant fields are upserted.
/// </summary>
private static void WriteCoreProperties(OpenXmlPackage doc, DateTime nowUtc)
{
var part = GetOrCreateCorePart(doc);
if (part == null) return;
XElement root;
try
{
using var rs = part.GetStream(FileMode.OpenOrCreate, FileAccess.Read);
if (rs.Length > 0)
{
var loaded = XDocument.Load(rs).Root;
root = loaded ?? new XElement(XName.Get("coreProperties", CpNs));
}
else
{
root = new XElement(XName.Get("coreProperties", CpNs));
}
}
catch
{
root = new XElement(XName.Get("coreProperties", CpNs));
}
void Upsert(string ns, string local, string value, bool withW3CDTF)
{
var name = XName.Get(local, ns);
var el = root.Element(name);
if (el == null)
{
el = new XElement(name, value);
if (withW3CDTF)
el.SetAttributeValue(XName.Get("type", XsiNs), "dcterms:W3CDTF");
root.Add(el);
}
else
{
el.Value = value;
if (withW3CDTF && el.Attribute(XName.Get("type", XsiNs)) == null)
el.SetAttributeValue(XName.Get("type", XsiNs), "dcterms:W3CDTF");
}
}
var iso = nowUtc.ToString("yyyy-MM-ddTHH:mm:ssZ");
Upsert(DcNs, "creator", CreatorName, withW3CDTF: false);
Upsert(DctermsNs, "created", iso, withW3CDTF: true);
Upsert(CpNs, "lastModifiedBy", CreatorName, withW3CDTF: false);
Upsert(DctermsNs, "modified", iso, withW3CDTF: true);
// Ensure idiomatic prefixes on the root for the standard four
// namespaces (Office writes these as cp/dc/dcterms/xsi). XDocument
// emits each child's namespace as default if no prefix is bound, so
// pin the prefixes explicitly.
SetXmlnsIfMissing(root, "cp", CpNs);
SetXmlnsIfMissing(root, "dc", DcNs);
SetXmlnsIfMissing(root, "dcterms", DctermsNs);
SetXmlnsIfMissing(root, "xsi", XsiNs);
using var ws = part.GetStream(FileMode.Create, FileAccess.Write);
var settings = new XmlWriterSettings
{
Encoding = new System.Text.UTF8Encoding(false),
OmitXmlDeclaration = false,
};
using var xw = XmlWriter.Create(ws, settings);
xw.WriteStartDocument(true);
root.WriteTo(xw);
}
private static void SetXmlnsIfMissing(XElement el, string prefix, string ns)
{
var attrName = XNamespace.Xmlns + prefix;
if (el.Attribute(attrName) == null)
el.SetAttributeValue(attrName, ns);
}
/// <summary>
/// Stamp a freshly-created document as authored by OfficeCLI. Writes
/// <c>docProps/core.xml</c> (Creator, Created, LastModifiedBy, Modified),
/// <c>docProps/app.xml</c> (Application = "OfficeCLI/&lt;version&gt;", no AppVersion),
/// and <c>docProps/custom.xml</c> (OfficeCLI.Version, OfficeCLI.LastModified).
///
/// Only invoked from <see cref="BlankDocCreator"/> on initial creation.
/// For edits to existing documents, use <see cref="StampOnSave"/> which
/// only updates the audit trail in <c>custom.xml</c> and leaves
/// <c>app.xml</c>'s &lt;Application&gt; untouched.
/// </summary>
public static void StampOnCreate(OpenXmlPackage doc)
{
var nowUtc = DateTime.UtcNow;
WriteCoreProperties(doc, nowUtc);
var part = ExtendedPropertiesHandler.GetOrCreateExtendedPart(doc);
if (part != null)
{
part.Properties ??= new AP.Properties();
(part.Properties.Application ??= new AP.Application()).Text = AppName;
part.Properties.Save();
}
WriteCustomProperties(doc, nowUtc);
}
/// <summary>
/// Stamp an audit trail on every save of an existing document. Writes
/// only <c>docProps/custom.xml</c> with OfficeCLI.Version and
/// OfficeCLI.LastModified. Does NOT touch &lt;Application&gt; in app.xml
/// (preserving the original authoring tool's identity) nor core.xml
/// (avoiding LastModifiedBy clobbering of the real human author).
/// </summary>
public static void StampOnSave(OpenXmlPackage doc)
{
WriteCustomProperties(doc, DateTime.UtcNow);
}
private const string CustomPropsFmtId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
private static CustomFilePropertiesPart? GetOrCreateCustomPart(OpenXmlPackage doc) => doc switch
{
WordprocessingDocument w => w.CustomFilePropertiesPart ?? w.AddCustomFilePropertiesPart(),
SpreadsheetDocument s => s.CustomFilePropertiesPart ?? s.AddCustomFilePropertiesPart(),
PresentationDocument p => p.CustomFilePropertiesPart ?? p.AddCustomFilePropertiesPart(),
_ => null
};
/// <summary>
/// Write/update OfficeCLI custom properties in <c>docProps/custom.xml</c>.
/// Properties used: <c>OfficeCLI.Version</c>, <c>OfficeCLI.LastModified</c>.
/// Existing properties (from any author) are preserved verbatim; only
/// the two OfficeCLI-owned keys are upserted, and <c>pid</c> values are
/// renumbered into the contiguous 2..N range the OOXML schema requires.
/// </summary>
private static void WriteCustomProperties(OpenXmlPackage doc, DateTime nowUtc)
{
var part = GetOrCreateCustomPart(doc);
if (part == null) return;
part.Properties ??= new CP.Properties();
var props = part.Properties;
void Upsert(string name, string value)
{
CP.CustomDocumentProperty? existing = null;
foreach (var el in props.Elements<CP.CustomDocumentProperty>())
{
if (string.Equals(el.Name?.Value, name, StringComparison.Ordinal))
{
existing = el;
break;
}
}
if (existing != null)
{
existing.RemoveAllChildren();
existing.AppendChild(new VT.VTLPWSTR(value));
return;
}
var added = new CP.CustomDocumentProperty
{
FormatId = CustomPropsFmtId,
Name = name,
};
added.AppendChild(new VT.VTLPWSTR(value));
props.AppendChild(added);
}
Upsert("OfficeCLI.Version", ResolveVersion());
Upsert("OfficeCLI.LastModified", nowUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"));
// OOXML requires pid to be a contiguous sequence starting at 2.
// Renumber every CustomDocumentProperty in document order so the
// schema stays valid regardless of prior authors' numbering.
int pid = 2;
foreach (var el in props.Elements<CP.CustomDocumentProperty>())
{
el.PropertyId = pid++;
}
props.Save();
}
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Single source of truth for the canonical default font scheme.
/// These literals appear in two contexts:
///
/// 1. Blank document creation — emitted into theme1.xml's fontScheme.
/// 2. Preview rendering fallback — when a document lacks any explicit
/// font (no run rPr, no styles.xml docDefaults, no theme part) the
/// HTML preview defaults to these values rather than the browser's
/// generic serif/sans default.
///
/// Note: when a document HAS a theme part, callers should prefer reading
/// <c>theme.fontScheme.MinorFont.LatinFont.Typeface</c> (or MajorFont
/// for headings) before falling back to these constants. The constants
/// are the *last* resort, not the first.
/// </summary>
public static class OfficeDefaultFonts
{
public const string MajorLatin = "Calibri Light";
public const string MinorLatin = "Calibri";
/// <summary>Excel default body font size (pt) when stylesheet Font[0] is missing.</summary>
public const string ExcelBodySizePt = "11";
}
@@ -0,0 +1,73 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Single source of truth for the canonical default color scheme
/// (the palette Word/Excel/PowerPoint apply when a document has no explicit
/// <c>a:theme</c> part). Used in two contexts:
///
/// 1. Blank document creation — emitted into the theme1.xml we write.
/// 2. Preview rendering fallback — when reading the doc's theme part
/// yields no <c>ColorScheme</c>, callers fall back to this palette
/// so <c>w:themeColor="accent1"</c> still resolves to a real hex
/// instead of silently dropping.
///
/// Hex values are 6-char OOXML format (no leading <c>#</c>).
/// </summary>
public static class OfficeDefaultThemeColors
{
public const string Accent1 = "4472C4";
public const string Accent2 = "ED7D31";
public const string Accent3 = "A5A5A5";
public const string Accent4 = "FFC000";
public const string Accent5 = "5B9BD5";
public const string Accent6 = "70AD47";
public const string Dark1 = "000000";
public const string Dark2 = "44546A";
public const string Light1 = "FFFFFF";
public const string Light2 = "E7E6E6";
public const string Hyperlink = "0563C1";
public const string FollowedHyperlink = "954F72";
/// <summary>
/// Default chart series color rotation when no <c>ColorScheme</c> is
/// available. Slots 1-6 are the six accent colors; slots 7-12 are the
/// same accents with <c>lumMod=75000</c> applied (the darker tints
/// Office cycles through after exhausting the primary accents).
///
/// Hex values are 6-char OOXML format (no leading <c>#</c>). Both the
/// OOXML chart Builder and the SVG preview Renderer derive from this
/// array — keep them aligned to avoid the chart-vs-preview drift.
/// </summary>
public static readonly string[] DefaultChartSeriesPalette =
{
Accent1, Accent2, Accent3, Accent4, Accent5, Accent6,
"264478", "9E480E", "636363", "997300", "255E91", "43682B",
};
/// <summary>
/// Builds a name→hex dictionary covering the canonical scheme keys plus
/// the common aliases (dk1/tx1/text1, bg1/lt1/background1, …) that Word
/// and PowerPoint accept as <c>w:themeColor</c> / <c>a:schemeClr</c>
/// references. Used by HTML preview fallbacks.
/// </summary>
public static Dictionary<string, string> BuildAliasMap() => new(StringComparer.OrdinalIgnoreCase)
{
["accent1"] = Accent1,
["accent2"] = Accent2,
["accent3"] = Accent3,
["accent4"] = Accent4,
["accent5"] = Accent5,
["accent6"] = Accent6,
["dark1"] = Dark1, ["tx1"] = Dark1, ["dk1"] = Dark1, ["text1"] = Dark1,
["dark2"] = Dark2, ["tx2"] = Dark2, ["dk2"] = Dark2, ["text2"] = Dark2,
["light1"] = Light1, ["bg1"] = Light1, ["lt1"] = Light1, ["background1"] = Light1,
["light2"] = Light2, ["bg2"] = Light2, ["lt2"] = Light2, ["background2"] = Light2,
["hyperlink"] = Hyperlink,
["followedHyperlink"] = FollowedHyperlink,
};
}
+789
View File
@@ -0,0 +1,789 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using DocumentFormat.OpenXml.Packaging;
namespace OfficeCli.Core;
/// <summary>
/// Shared helpers for OLE (Object Linking and Embedding) support across
/// Word/Excel/PowerPoint handlers. Covers:
/// - ProgID auto-detection from file extension
/// - Mapping src file extensions to the right embedded PartTypeInfo
/// - A tiny placeholder PNG used as the visual icon for new OLE objects
/// - Populating canonical DocumentNode.Format fields from an embedded part
///
/// Design: all three handlers consume the same helper so that a single call
/// site governs progId defaults, content-type decisions, and node shape.
/// This keeps the "ole" node schema consistent across .docx/.xlsx/.pptx.
/// </summary>
internal static class OleHelper
{
/// <summary>
/// Detect the OLE ProgID to use when the caller did not supply one.
/// Returns identifiers that match what Word/Excel/PowerPoint register
/// at install time on Windows; all three are version-12 ProgIDs that
/// real Office uses for embedded round-tripping. Unknown extensions
/// fall back to "Package", the generic "wrapper for an opaque file"
/// ProgID that any Office host will open via its registered handler.
/// </summary>
public static string DetectProgId(string srcPath)
{
var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant();
return ext switch
{
"docx" or "docm" or "dotx" or "dotm" => "Word.Document.12",
"doc" => "Word.Document.8",
"xlsx" or "xlsm" or "xlsb" or "xltx" or "xltm" => "Excel.Sheet.12",
"xls" => "Excel.Sheet.8",
"pptx" or "pptm" or "ppsx" or "ppsm" or "potx" or "potm" => "PowerPoint.Show.12",
"ppt" => "PowerPoint.Show.8",
"pdf" => "AcroExch.Document",
"vsdx" or "vsdm" or "vsd" => "Visio.Drawing",
_ => "Package",
};
}
/// <summary>
/// Map a ProgID to the document family it claims to host (word / excel /
/// powerpoint / package). Used to detect a mismatch between a freshly
/// supplied progId and the embedded part's actual MIME — flipping progId
/// to "Word.Document.12" while the embedded part is still a spreadsheet
/// produces an OOXML file Office cannot activate.
/// </summary>
public static string ProgIdFamily(string? progId)
{
if (string.IsNullOrEmpty(progId)) return "unknown";
var p = progId.ToLowerInvariant();
if (p.StartsWith("word.")) return "word";
if (p.StartsWith("excel.")) return "excel";
if (p.StartsWith("powerpoint.")) return "powerpoint";
if (p == "package" || p.StartsWith("package.")) return "package";
return "other";
}
/// <summary>
/// Map an embedded part's MIME content-type to the same family axis as
/// <see cref="ProgIdFamily"/> so the two can be compared.
/// </summary>
public static string ContentTypeFamily(string? contentType)
{
if (string.IsNullOrEmpty(contentType)) return "unknown";
var c = contentType.ToLowerInvariant();
if (c.Contains("wordprocessingml")) return "word";
if (c.Contains("spreadsheetml")) return "excel";
if (c.Contains("presentationml")) return "powerpoint";
if (c.Contains("oleobject") || c.Contains("package")) return "package";
return "other";
}
/// <summary>
/// Classifier for the content-type axis: Office files get an
/// <see cref="EmbeddedPackagePart"/> with the matching OOXML MIME,
/// everything else gets a generic <see cref="EmbeddedObjectPart"/>.
/// This mirrors how real Office writes OLE objects — OOXML documents
/// embed as x/vnd.openxmlformats-* package parts, binary or legacy
/// content lands in the generic "oleObject" bucket.
/// </summary>
public enum EmbeddingKind
{
/// <summary>Use EmbeddedPackagePart (for .docx/.xlsx/.pptx and their macro/template siblings).</summary>
Package,
/// <summary>Use EmbeddedObjectPart (for arbitrary binaries — PDF, Visio, .bin, etc.).</summary>
Object,
}
/// <summary>
/// Decide whether a source file should be embedded as a Package part
/// (strongly-typed OOXML container) or a generic Object part.
/// </summary>
public static EmbeddingKind ClassifyKind(string srcPath)
{
var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant();
return ext switch
{
"docx" or "docm" or "dotx" or "dotm"
or "xlsx" or "xlsm" or "xlsb" or "xltx" or "xltm"
or "pptx" or "pptm" or "ppsx" or "ppsm" or "potx" or "potm"
or "sldx" or "sldm" or "xlam" or "ppam" or "thmx"
=> EmbeddingKind.Package,
_ => EmbeddingKind.Object,
};
}
/// <summary>
/// Map an OOXML-family extension to its EmbeddedPackagePartType entry.
/// Returns null if the extension is not a recognized Office format,
/// in which case the caller should use <see cref="EmbeddedObjectPart"/>
/// with a generic content type.
/// </summary>
public static PartTypeInfo? GetPackagePartTypeInfo(string srcPath)
{
var ext = Path.GetExtension(srcPath).TrimStart('.').ToLowerInvariant();
return ext switch
{
"docx" => EmbeddedPackagePartType.Docx,
"docm" => EmbeddedPackagePartType.Docm,
"dotx" => EmbeddedPackagePartType.Dotx,
"dotm" => EmbeddedPackagePartType.Dotm,
"xlsx" => EmbeddedPackagePartType.Xlsx,
"xlsm" => EmbeddedPackagePartType.Xlsm,
"xlsb" => EmbeddedPackagePartType.Xlsb,
"xltx" => EmbeddedPackagePartType.Xltx,
"xltm" => EmbeddedPackagePartType.Xltm,
"xlam" => EmbeddedPackagePartType.Xlam,
"pptx" => EmbeddedPackagePartType.Pptx,
"pptm" => EmbeddedPackagePartType.Pptm,
"ppsx" => EmbeddedPackagePartType.Ppsx,
"ppsm" => EmbeddedPackagePartType.Ppsm,
"potx" => EmbeddedPackagePartType.Potx,
"potm" => EmbeddedPackagePartType.Potm,
"ppam" => EmbeddedPackagePartType.Ppam,
"sldx" => EmbeddedPackagePartType.Sldx,
"sldm" => EmbeddedPackagePartType.Sldm,
"thmx" => EmbeddedPackagePartType.Thmx,
_ => null,
};
}
/// <summary>
/// Add an embedded part (package or generic object) to the given host
/// part, feed it the source file bytes, and return the rel id.
/// Works for any parent that supports embedded parts: MainDocumentPart,
/// WorksheetPart, SlidePart.
/// </summary>
public static (string RelId, OpenXmlPart Part) AddEmbeddedPart(OpenXmlPart host, string srcPath, string? hostDocumentPath = null)
{
if (!File.Exists(srcPath))
throw new FileNotFoundException($"OLE source file not found: {srcPath}");
// Warn (don't throw) when the source file is zero bytes and it is NOT
// a self-embed. Self-embed intentionally writes a zero-byte placeholder
// (see CONSISTENCY(ole-self-embed) block below) and should stay silent.
// Non-self-embed 0-byte files usually indicate a truncated or missing
// payload — the user deserves a visible warning so they know the
// embedded bytes are empty. We still proceed with the embed to match
// the existing "silently ignored → visibly ignored" contract.
var isSelfEmbed = hostDocumentPath != null && IsSameFile(srcPath, hostDocumentPath);
if (!isSelfEmbed && new FileInfo(srcPath).Length == 0)
{
Console.Error.WriteLine(
$"Warning: OLE source file is empty (0 bytes): {srcPath}. Document will embed an empty payload.");
}
var kind = ClassifyKind(srcPath);
OpenXmlPart part;
if (kind == EmbeddingKind.Package)
{
var pt = GetPackagePartTypeInfo(srcPath)
?? EmbeddedPackagePartType.Xlsx; // should never hit, classified as Package
part = host switch
{
MainDocumentPart mdp => mdp.AddEmbeddedPackagePart(pt),
WorksheetPart wp => wp.AddEmbeddedPackagePart(pt),
SlidePart sp => sp.AddEmbeddedPackagePart(pt),
HeaderPart hp => hp.AddEmbeddedPackagePart(pt),
FooterPart fp => fp.AddEmbeddedPackagePart(pt),
_ => throw new InvalidOperationException(
$"Host part type {host.GetType().Name} does not support embedded packages"),
};
}
else
{
// Generic: use content-type that Office writes for "Package" OLE.
// The literal OOXML content type for an oleObject is documented as
// "application/vnd.openxmlformats-officedocument.oleObject".
var ct = "application/vnd.openxmlformats-officedocument.oleObject";
part = host switch
{
MainDocumentPart mdp => mdp.AddEmbeddedObjectPart(ct),
WorksheetPart wp => wp.AddEmbeddedObjectPart(ct),
SlidePart sp => sp.AddEmbeddedObjectPart(ct),
HeaderPart hp => hp.AddEmbeddedObjectPart(ct),
FooterPart fp => fp.AddEmbeddedObjectPart(ct),
_ => throw new InvalidOperationException(
$"Host part type {host.GetType().Name} does not support embedded objects"),
};
}
// CONSISTENCY(ole-self-embed): when srcPath refers to the host
// document itself, the SDK holds an exclusive package lock and any
// FileStream.Open() against srcPath fails with IOException. In that
// case feed a zero-byte placeholder payload so the OLE element and
// relationship are still created — callers can Get() the resulting
// node and reopen the document without corruption. The user-facing
// contract is: "self-embed is allowed and does not crash, but the
// embedded bytes are a placeholder rather than the host's literal
// snapshot" (which would require cloning the in-memory package).
if (hostDocumentPath != null && IsSameFile(srcPath, hostDocumentPath))
{
using var emptyMs = new MemoryStream(Array.Empty<byte>());
part.FeedData(emptyMs);
var selfRelId = host.GetIdOfPart(part);
return (selfRelId, part);
}
// First try FileShare.ReadWrite so concurrent writers do not crash;
// if that still fails (exclusive package lock / non-self-embed race),
// surface the exception to the caller with an actionable hint —
// commonly it is an officecli resident/watch process holding the
// source file open, in which case `officecli close <path>` unblocks
// the embed. We keep the detection-free approach (just add the hint
// to every IOException) so the helper stays dependency-free and the
// message is useful even for non-officecli holders.
//
// CONSISTENCY(ole-orphan-cleanup): if FileStream.Open() or FeedData()
// fails after the host part has been created, delete the dangling
// part so we don't leave an orphan EmbeddedPackagePart/EmbeddedObjectPart
// on the host (which would inflate part counts and survive into
// the saved file). The part was just added by AddEmbeddedPackagePart/
// AddEmbeddedObjectPart above — at this point nothing else references
// it, so DeletePart is safe.
try
{
byte[] srcBytes;
try
{
srcBytes = File.ReadAllBytes(srcPath);
}
catch (IOException ioEx)
{
throw new IOException(
$"Cannot read OLE source file '{srcPath}': the file is locked by another process. " +
$"If an officecli resident or watch process has this file open, run " +
$"'officecli close {srcPath}' first, then retry.", ioEx);
}
// CONSISTENCY(ole-cfb-wrap): non-Office payloads (.pdf/.txt/binary)
// must be wrapped in a CFB container with a \x01Ole10Native stream.
// Excel rejects the file (0x800A03EC) otherwise. Office OOXML
// payloads are embedded raw via EmbeddedPackagePart — Excel reads
// them directly using the progId (Word.Document.12 / etc).
byte[] payload = kind == EmbeddingKind.Object
? BuildOle10NativeCfb(srcBytes, Path.GetFileName(srcPath))
: srcBytes;
using var payloadStream = new MemoryStream(payload);
part.FeedData(payloadStream);
}
catch
{
try { host.DeletePart(part); } catch { /* best effort */ }
throw;
}
var relId = host.GetIdOfPart(part);
return (relId, part);
}
/// <summary>
/// dump→batch round-trip: create the embedded payload part from raw bytes
/// that are ALREADY in their final embedded form (a raw Office package for
/// EmbeddedPackagePart, or CFB-wrapped Ole10Native for EmbeddedObjectPart).
/// Unlike <see cref="AddEmbeddedPart"/>, this feeds the bytes verbatim — no
/// extension-based classification and no CFB re-wrapping — because the
/// source bytes captured by the dump are exactly what must be written back.
/// <paramref name="oleKind"/> ("package"/"object"), <paramref name="contentType"/>
/// and <paramref name="embedExt"/> come from the source part so the rebuilt
/// relationship type, content type and target extension match byte-for-byte.
/// <paramref name="relId"/>, when set, pins the relationship id — required
/// by carriers whose host XML is replayed verbatim (the r:id references
/// inside it cannot be rewritten to an SDK-assigned id).
/// </summary>
public static (string RelId, OpenXmlPart Part) AddEmbeddedPartFromBytes(
OpenXmlPart host, byte[] raw, string oleKind, string contentType, string? embedExt,
string? relId = null)
{
var isPackage = string.Equals(oleKind, "package", StringComparison.OrdinalIgnoreCase);
OpenXmlPart part;
if (isPackage)
{
// Reconstruct the exact package part type from the source content
// type + target extension (PartTypeInfo's public (ct, ext) ctor),
// so legacy (.xls → application/vnd.ms-excel) and modern formats
// alike round-trip without a hardcoded content-type table.
var pt = new PartTypeInfo(contentType, string.IsNullOrEmpty(embedExt) ? "bin" : embedExt);
part = (host, relId) switch
{
(MainDocumentPart mdp, null) => mdp.AddEmbeddedPackagePart(pt),
(MainDocumentPart mdp, _) => mdp.AddEmbeddedPackagePart(pt, relId),
(WorksheetPart wp, null) => wp.AddEmbeddedPackagePart(pt),
(WorksheetPart wp, _) => wp.AddEmbeddedPackagePart(pt, relId),
(SlidePart sp, null) => sp.AddEmbeddedPackagePart(pt),
(SlidePart sp, _) => sp.AddEmbeddedPackagePart(pt, relId),
(HeaderPart hp, null) => hp.AddEmbeddedPackagePart(pt),
(HeaderPart hp, _) => hp.AddEmbeddedPackagePart(pt, relId),
(FooterPart fp, null) => fp.AddEmbeddedPackagePart(pt),
(FooterPart fp, _) => fp.AddEmbeddedPackagePart(pt, relId),
_ => throw new InvalidOperationException(
$"Host part type {host.GetType().Name} does not support embedded packages"),
};
}
else
{
var ct = string.IsNullOrEmpty(contentType)
? "application/vnd.openxmlformats-officedocument.oleObject"
: contentType;
part = (host, relId) switch
{
(MainDocumentPart mdp, null) => mdp.AddEmbeddedObjectPart(ct),
(MainDocumentPart mdp, _) => mdp.AddEmbeddedObjectPart(ct, relId),
(WorksheetPart wp, null) => wp.AddEmbeddedObjectPart(ct),
(WorksheetPart wp, _) => wp.AddEmbeddedObjectPart(ct, relId),
(SlidePart sp, null) => sp.AddEmbeddedObjectPart(ct),
(SlidePart sp, _) => sp.AddEmbeddedObjectPart(ct, relId),
(HeaderPart hp, null) => hp.AddEmbeddedObjectPart(ct),
(HeaderPart hp, _) => hp.AddEmbeddedObjectPart(ct, relId),
(FooterPart fp, null) => fp.AddEmbeddedObjectPart(ct),
(FooterPart fp, _) => fp.AddEmbeddedObjectPart(ct, relId),
_ => throw new InvalidOperationException(
$"Host part type {host.GetType().Name} does not support embedded objects"),
};
}
try
{
using var ms = new MemoryStream(raw);
part.FeedData(ms);
}
catch
{
try { host.DeletePart(part); } catch { /* best effort */ }
throw;
}
return (host.GetIdOfPart(part), part);
}
/// <summary>
/// Parse a <c>data:&lt;contentType&gt;;base64,&lt;payload&gt;</c> URI into its
/// content type and decoded bytes. Returns false for any non-data-URI or
/// malformed input (caller falls back to file-path handling).
/// </summary>
public static bool TryDecodeDataUri(string? src, out byte[] bytes, out string contentType)
{
bytes = Array.Empty<byte>();
contentType = "";
if (string.IsNullOrEmpty(src) || !src.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
return false;
var comma = src.IndexOf(',');
if (comma < 0) return false;
var meta = src.Substring(5, comma - 5); // between "data:" and ","
var payload = src[(comma + 1)..];
var isBase64 = meta.EndsWith(";base64", StringComparison.OrdinalIgnoreCase);
if (isBase64) meta = meta[..^7];
contentType = meta; // may be empty
try
{
bytes = isBase64
? Convert.FromBase64String(payload)
: System.Text.Encoding.UTF8.GetBytes(Uri.UnescapeDataString(payload));
}
catch
{
return false;
}
return true;
}
/// <summary>
/// Returns true if <paramref name="candidatePath"/> resolves to the same
/// file as <paramref name="hostDocumentPath"/>. Used by handlers to
/// detect self-embed Set(src=hostPath) so they can substitute a
/// zero-byte or placeholder payload instead of crashing when the SDK
/// holds an exclusive package lock on the host file.
/// </summary>
public static bool IsSameFile(string candidatePath, string hostDocumentPath)
{
if (string.IsNullOrEmpty(candidatePath) || string.IsNullOrEmpty(hostDocumentPath))
return false;
try
{
var a = Path.GetFullPath(candidatePath);
var b = Path.GetFullPath(hostDocumentPath);
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
/// <summary>
/// Populate canonical OLE fields on a DocumentNode from the backing
/// embedded part. Reads content type and byte length so consumers see
/// the same shape regardless of whether the part was EmbeddedObject or
/// EmbeddedPackage.
/// </summary>
public static void PopulateFromPart(DocumentNode node, OpenXmlPart part, string? progId = null)
{
node.Type = "ole";
node.Format["objectType"] = "ole";
if (!string.IsNullOrEmpty(progId))
{
node.Format["progId"] = progId;
if (string.IsNullOrEmpty(node.Text))
node.Text = progId;
}
node.Format["contentType"] = part.ContentType;
try
{
// CONSISTENCY(ole-cfb-wrap): fileSize reports the logical payload
// size (as fed via `add ole src=...`), not the on-disk CFB wrapper
// size. Read the stream fully and unwrap Ole10Native if CFB.
using var s = part.GetStream();
using var ms = new MemoryStream();
s.CopyTo(ms);
var raw = ms.ToArray();
var payload = UnwrapOle10NativeIfCfb(raw);
node.Format["fileSize"] = (long)payload.Length;
}
catch
{
// part stream may be transient during write; ignore
}
}
/// <summary>
/// Minimal valid 1x1 transparent PNG used as the icon preview for
/// newly-inserted OLE objects. Office requires a visual placeholder;
/// the size is irrelevant because the host shape's explicit extents
/// govern display dimensions. This is the same byte sequence used by
/// <c>PowerPointHandler.AddMedia</c> for its poster fallback, known
/// to decode cleanly in every consumer we test against.
/// </summary>
public static byte[] PlaceholderIconPng => _placeholderPng;
// 1x1 transparent PNG, precomputed. Verified valid by the existing
// PowerPointHandler media poster path.
private static readonly byte[] _placeholderPng =
{
0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,
0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,
0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x06,0x00,0x00,0x00,0x1F,0x15,0xC4,0x89,
0x00,0x00,0x00,0x0D,0x49,0x44,0x41,0x54,
0x08,0xD7,0x63,0x60,0x60,0x60,0x60,0x00,0x00,0x00,0x05,0x00,0x01,0x87,0xA1,0x4E,0xD4,
0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,0xAE,0x42,0x60,0x82,
};
/// <summary>
/// Compute default icon dimensions in EMU when the caller didn't supply
/// width/height. 2 inches × 0.75 inches matches what Office uses for a
/// default "show as icon" OLE frame, sized to fit the file-type label.
/// </summary>
public const long DefaultOleWidthEmu = 1828800; // 2 inches
public const long DefaultOleHeightEmu = 685800; // 0.75 inches
/// <summary>
/// Validate a COM ProgID string against the well-known Windows COM
/// constraints: the identifier must be 1..39 characters long and must
/// not start with a digit. OLE spec (MSDN "ProgID") is explicit on both
/// rules. Handlers previously accepted arbitrary strings silently; this
/// method gives users an early, actionable error instead of writing an
/// invalid OLE element that Office refuses to open.
/// </summary>
public static void ValidateProgId(string progId)
{
if (progId == null) return;
if (progId.Length > 39)
throw new ArgumentException(
$"progId '{progId}' exceeds 39 characters (limit: 39, actual: {progId.Length}).");
if (progId.Length > 0 && char.IsDigit(progId[0]))
throw new ArgumentException(
$"progId '{progId}' cannot start with a digit.");
// COM ProgID character set: letters, digits, '.', '_', '-'. Anything
// else (notably XML-unsafe characters like '<', '>', '&', '"') would
// either corrupt the OOXML progId attribute or be rejected by Office
// on reopen. Reject early with an actionable error instead of letting
// bad bytes land in the package.
foreach (var ch in progId)
{
if (!(char.IsLetterOrDigit(ch) || ch == '.' || ch == '_' || ch == '-'))
throw new ArgumentException(
$"progId '{progId}' contains invalid characters. Only letters, digits, '.', '_', '-' are allowed.");
}
}
/// <summary>
/// Normalize and validate the caller-supplied <c>display</c> property
/// for an OLE object. Canonical values are <c>"icon"</c> (show the file
/// as a clickable icon preview) and <c>"content"</c> (show the embedded
/// file's first page as a live picture). Any other value — including
/// ambiguous synonyms like <c>"embed"</c>, <c>"invisible"</c>, numbers,
/// or boolean strings — is rejected with <see cref="ArgumentException"/>
/// so the user is told their input was wrong instead of silently
/// falling back to "icon". Used by Word/PPT Add and Set.
/// </summary>
public static string NormalizeOleDisplay(string value)
{
if (value == null)
throw new ArgumentException(
"Invalid display value ''. Expected 'icon' or 'content'.");
var v = value.Trim().ToLowerInvariant();
if (v == "icon") return "icon";
if (v == "content") return "content";
throw new ArgumentException(
$"Invalid display value '{value}'. Expected 'icon' or 'content'.");
}
/// <summary>
/// Known OLE Add/Set property keys shared across Word/PPT/Excel. Used by
/// <see cref="WarnOnUnknownOleProps"/> to surface silently-ignored
/// properties via stderr. Kept as a single union so the three handlers
/// stay consistent — per-handler differences (e.g. Excel's "anchor"
/// range string) are all represented here.
/// </summary>
private static readonly HashSet<string> KnownOleProps = new(StringComparer.OrdinalIgnoreCase)
{
"src", "path", "progId", "progid",
"width", "height", "x", "y",
"icon", "preview", "display", "name",
"anchor",
// dump→batch round-trip carrier keys: when src is a data: URI the
// payload bytes are already in final embedded form, so oleKind +
// contentType + embedExt let AddOle rebuild the exact part class /
// content type / target extension without classifying by file ext.
"oleKind", "olekind", "contentType", "contenttype", "embedExt", "embedext",
// Frame/preview carrier keys: the verbatim floating v:shape style, the
// w:object native box (dxaOrig/dyaOrig), and the VML <v:imagedata> crop
// rectangle. All are written by the OLE dump and consumed by AddOle so a
// floating, cropped object round-trips at its original size.
"shapeStyle", "shapestyle", "dxaOrig", "dxaorig", "dyaOrig", "dyaorig", "crop",
// The OLE run's own <w:rPr> (font/border/size). The wrapping run can carry
// a <w:bdr> border box or rFonts/sz that set the host line height; AddOle
// re-applies it so the object's border + line metrics round-trip.
"runRpr", "runrpr",
// Tracked-change attribution: a deleted/inserted/moved OLE object carries
// its revision wrapper through dump→batch so AddOle re-wraps the run in
// <w:del>/<w:ins>/move (else a deleted figure resurrects as live content).
"revision.type", "revision.author", "revision.date", "revision.id",
};
/// <summary>
/// Emit a single-line stderr warning for every property key in
/// <paramref name="properties"/> that is not in <see cref="KnownOleProps"/>.
/// The Add handler signature returns a string and cannot carry a
/// structured warning list back to the caller, so we surface unknown
/// keys via Console.Error to match the "silently ignored → visibly
/// ignored" expectation. No-op when <paramref name="properties"/> is
/// null or empty.
/// </summary>
public static void WarnOnUnknownOleProps(Dictionary<string, string>? properties)
{
if (properties == null || properties.Count == 0) return;
foreach (var key in properties.Keys)
{
if (!KnownOleProps.Contains(key))
Console.Error.WriteLine($"warning: unknown ole property '{key}' — ignored");
}
}
// ==================== Shared Add helpers ====================
//
// The following methods extract duplicated boilerplate that previously
// appeared verbatim in Word/Excel/PowerPoint AddOle handlers.
/// <summary>
/// Validate and extract the required <c>src</c> (or <c>path</c>) property
/// from the caller-supplied dictionary. Throws
/// <see cref="ArgumentException"/> when neither key is present or the
/// value is blank.
/// </summary>
public static string RequireSource(Dictionary<string, string>? properties)
{
properties ??= new Dictionary<string, string>();
if (!properties.TryGetValue("src", out var srcPath)
&& !properties.TryGetValue("path", out srcPath))
throw new ArgumentException("'src' property is required for ole type");
if (string.IsNullOrWhiteSpace(srcPath))
throw new ArgumentException("'src' property for ole type cannot be empty");
return srcPath;
}
/// <summary>
/// Resolve the ProgID from explicit property → auto-detected from
/// extension, then validate. Replaces the 4-line fallback chain that
/// was duplicated in every handler.
/// </summary>
public static string ResolveProgId(Dictionary<string, string> properties, string srcPath)
{
var progId = properties.GetValueOrDefault("progId")
?? properties.GetValueOrDefault("progid")
?? DetectProgId(srcPath);
ValidateProgId(progId);
return progId;
}
/// <summary>
/// Create the icon preview <see cref="ImagePart"/> on the given host
/// part — either from the user-supplied <c>icon</c> / <c>preview</c>
/// property (synonyms — both name the embedded blip thumbnail) or the
/// default 1×1 placeholder PNG. Returns the relationship id.
/// </summary>
public static (ImagePart Part, string RelId) CreateIconPart(OpenXmlPart host, Dictionary<string, string> properties)
{
ImagePart iconPart;
// CONSISTENCY(ole-preview-alias): `preview` mirrors `icon` to honour the
// schemas/help/_shared/ole.json declaration (preview { add: true }).
// Both point at the same OOXML element — the inner p:pic blip — so we
// accept either spelling rather than maintain two code paths.
if ((properties.TryGetValue("icon", out var iconPath)
|| properties.TryGetValue("preview", out iconPath))
&& !string.IsNullOrWhiteSpace(iconPath))
{
var (iconStream, iconType) = ImageSource.Resolve(iconPath);
using var _ = iconStream;
iconPart = AddImagePartTo(host, iconType);
iconPart.FeedData(iconStream);
}
else
{
iconPart = AddImagePartTo(host, ImagePartType.Png);
using var ms = new MemoryStream(PlaceholderIconPng);
iconPart.FeedData(ms);
}
return (iconPart, host.GetIdOfPart(iconPart));
}
/// <summary>
/// Dispatch <see cref="OpenXmlPart.AddImagePart"/> to the correct
/// concrete host type. Covers all part types that can own OLE objects.
/// </summary>
private static ImagePart AddImagePartTo(OpenXmlPart host, PartTypeInfo type)
=> host switch
{
MainDocumentPart mdp => mdp.AddImagePart(type),
HeaderPart hp => hp.AddImagePart(type),
FooterPart fp => fp.AddImagePart(type),
WorksheetPart wp => wp.AddImagePart(type),
SlidePart sp => sp.AddImagePart(type),
DrawingsPart dp => dp.AddImagePart(type),
_ => throw new InvalidOperationException(
$"Host part type {host.GetType().Name} does not support image parts"),
};
/// <summary>
/// Wrap an arbitrary payload (pdf/txt/binary) in an OLE1.0 Ole10Native
/// stream inside a CFB (Compound File Binary) container. This is the
/// shape Excel expects for generic "Package" OLE embeddings — without
/// it, Excel rejects the host .xlsx at open with 0x800A03EC.
///
/// Ole10Native stream layout (little-endian):
/// uint32 total size of remaining bytes
/// uint16 version (0x0002)
/// cstring display name (ANSI, null-terminated)
/// cstring original file path (ANSI, null-terminated — may be bogus)
/// uint32 reserved (0)
/// uint32 reserved (0)
/// cstring temp path (ANSI, null-terminated)
/// uint32 payload size
/// byte[] payload
/// </summary>
public static byte[] BuildOle10NativeCfb(byte[] payload, string displayName)
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (string.IsNullOrEmpty(displayName)) displayName = "embedded.bin";
// Build the \x01Ole10Native stream body.
byte[] streamBody;
using (var ms = new MemoryStream())
using (var w = new BinaryWriter(ms))
{
// Use ASCII-safe rendering of the display name. Non-ASCII chars
// get best-effort '?' substitution (ANSI constraint of the OLE1
// wire format; Excel only displays this).
string ansiName = SanitizeAnsi(displayName);
string fakePath = "C:\\" + ansiName;
// Reserve 4 bytes for total-size prefix; fill in at the end.
w.Write((uint)0);
w.Write((ushort)0x0002);
WriteCString(w, ansiName);
WriteCString(w, fakePath);
w.Write((uint)0);
w.Write((uint)0);
WriteCString(w, ansiName);
w.Write((uint)payload.Length);
w.Write(payload);
// Backfill total size = entire body length minus the 4-byte prefix.
long end = ms.Position;
ms.Position = 0;
w.Write((uint)(end - 4));
ms.Position = end;
streamBody = ms.ToArray();
}
// Wrap in a CFB container with a single stream named "\x01Ole10Native".
// The CFB writer is owned in-tree (Core/CompoundFile.cs) so there is
// no third-party structured-storage dependency.
return CompoundFile.WriteSingleStream("\u0001Ole10Native", streamBody);
}
/// <summary>
/// If <paramref name="raw"/> starts with CFB magic bytes and contains a
/// single <c>\x01Ole10Native</c> stream, return the unwrapped payload.
/// Otherwise return <paramref name="raw"/> unchanged. This is the
/// counterpart to <see cref="BuildOle10NativeCfb"/> — after we wrap
/// non-Office payloads at embed time, <c>TryExtractBinary</c> has to
/// strip the wrapping so callers see the bytes they fed in.
/// </summary>
public static byte[] UnwrapOle10NativeIfCfb(byte[] raw)
{
if (raw == null || raw.Length < 8) return raw ?? Array.Empty<byte>();
// CFB magic: D0 CF 11 E0 A1 B1 1A E1
if (raw[0] != 0xD0 || raw[1] != 0xCF || raw[2] != 0x11 || raw[3] != 0xE0 ||
raw[4] != 0xA1 || raw[5] != 0xB1 || raw[6] != 0x1A || raw[7] != 0xE1)
return raw;
try
{
byte[]? native = CompoundFile.ReadStream(raw, "\u0001Ole10Native");
if (native == null) return raw;
// Parse Ole10Native header: uint32 totalSize, uint16 version,
// cstring name, cstring path, 8 bytes reserved, cstring temp,
// uint32 payloadSize, bytes payload.
using var br = new BinaryReader(new MemoryStream(native, writable: false));
br.ReadUInt32(); // totalSize
br.ReadUInt16(); // version
ReadCString(br); // displayName
ReadCString(br); // origPath
br.ReadUInt32(); // reserved1
br.ReadUInt32(); // reserved2
ReadCString(br); // tempPath
uint payloadSize = br.ReadUInt32();
if (payloadSize > int.MaxValue) return raw;
return br.ReadBytes((int)payloadSize);
}
catch
{
return raw;
}
}
private static string ReadCString(BinaryReader br)
{
var sb = new System.Text.StringBuilder();
while (true)
{
byte b = br.ReadByte();
if (b == 0) break;
sb.Append((char)b);
}
return sb.ToString();
}
private static void WriteCString(BinaryWriter w, string s)
{
foreach (char c in s)
w.Write(c < 0x80 ? (byte)c : (byte)'?');
w.Write((byte)0);
}
private static string SanitizeAnsi(string s)
{
var chars = new char[s.Length];
for (int i = 0; i < s.Length; i++)
chars[i] = s[i] < 0x80 && s[i] >= 0x20 ? s[i] : '_';
return new string(chars);
}
}
+719
View File
@@ -0,0 +1,719 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace OfficeCli.Core;
internal enum OutputFormat
{
Text,
Json
}
internal class ViewResult
{
[JsonPropertyName("view")]
public string View { get; set; } = "";
[JsonPropertyName("content")]
public string Content { get; set; } = "";
}
internal class NodesResult
{
[JsonPropertyName("matches")]
public int Matches { get; set; }
[JsonPropertyName("results")]
public List<DocumentNode> Results { get; set; } = new();
}
internal class IssuesResult
{
[JsonPropertyName("count")]
public int Count { get; set; }
[JsonPropertyName("issues")]
public List<DocumentIssue> Issues { get; set; } = new();
}
internal class ErrorResult
{
[JsonPropertyName("error")]
public string Error { get; set; } = "";
[JsonPropertyName("code")]
public string? Code { get; set; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; set; }
[JsonPropertyName("help")]
public string? Help { get; set; }
[JsonPropertyName("validValues")]
public string[]? ValidValues { get; set; }
}
internal class CliWarning
{
[JsonPropertyName("message")]
public string Message { get; set; } = "";
[JsonPropertyName("code")]
public string? Code { get; set; }
[JsonPropertyName("suggestion")]
public string? Suggestion { get; set; }
// Machine-readable correction fields for the query self-correction contract.
// Null on warnings without structured data and omitted from JSON
// (WhenWritingNull), so these are purely additive for existing consumers.
[JsonPropertyName("kind")]
public string? Kind { get; set; }
[JsonPropertyName("key")]
public string? Key { get; set; }
[JsonPropertyName("value")]
public string? Value { get; set; }
[JsonPropertyName("available")]
public string[]? Available { get; set; }
}
/// <summary>
/// Thread-static context for capturing warnings during command execution in JSON mode.
/// </summary>
internal static class WarningContext
{
[ThreadStatic]
private static List<CliWarning>? _warnings;
public static void Begin() => _warnings = new List<CliWarning>();
public static void Add(string message, string? code = null, string? suggestion = null)
{
_warnings?.Add(new CliWarning { Message = message, Code = code, Suggestion = suggestion });
}
public static List<CliWarning>? End()
{
var result = _warnings;
_warnings = null;
return result?.Count > 0 ? result : null;
}
public static bool IsActive => _warnings != null;
}
[JsonSourceGenerationOptions(
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(ViewResult))]
[JsonSerializable(typeof(NodesResult))]
[JsonSerializable(typeof(IssuesResult))]
[JsonSerializable(typeof(ErrorResult))]
[JsonSerializable(typeof(CliWarning))]
[JsonSerializable(typeof(List<CliWarning>))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(DocumentNode))]
[JsonSerializable(typeof(List<DocumentNode>))]
[JsonSerializable(typeof(List<DocumentIssue>))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(long))]
[JsonSerializable(typeof(short))]
[JsonSerializable(typeof(uint))]
// OOXML UInt16Value/ByteValue/UInt32Value/SByteValue/UInt64Value frequently
// land in DocumentNode.Format[] as boxed primitives (e.g. chart hole/skip/
// rotateX/style/firstSliceAngle). Without these JsonSerializable hooks the
// source-gen polymorphic writer throws JsonTypeInfo missing-metadata when
// `get --json` hits a node carrying any of them. See R43-6.
[JsonSerializable(typeof(ushort))]
[JsonSerializable(typeof(byte))]
[JsonSerializable(typeof(sbyte))]
[JsonSerializable(typeof(ulong))]
[JsonSerializable(typeof(float))]
[JsonSerializable(typeof(decimal))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(string))]
internal partial class AppJsonContext : JsonSerializerContext;
internal static class OutputFormatter
{
public static readonly JsonSerializerOptions PublicJsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = AppJsonContext.Default
};
/// <summary>
/// Wraps pre-serialized data JSON into a unified envelope with optional warnings.
/// Output: { "success": true|false, "data": ..., "warnings": [...] }
///
/// CONTRACT: `success` reflects the *business* outcome of the command, not
/// process liveness. Pass `success: false` when the command ran to
/// completion but its judgment is "failed" (e.g. validate found schema
/// errors, batch had a failed step). For *probe* commands like
/// `view --mode issues`, success stays true even when issues are listed —
/// listing issues is the command's normal output, not a failure verdict.
/// See the project conventions "JSON Envelope" for the per-command judgment table.
/// </summary>
public static string WrapEnvelope(string dataJson, List<CliWarning>? warnings = null, bool success = true)
{
var envelope = new JsonObject { ["success"] = success };
// Parse and embed data as-is (preserves original structure)
try { envelope["data"] = JsonNode.Parse(dataJson); }
catch { envelope["data"] = dataJson; } // fallback: plain string
if (warnings is { Count: > 0 })
envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning);
return envelope.ToJsonString(JsonOptions);
}
/// <summary>
/// Wraps a plain text result (like "Updated ..." or "Added ...") into an envelope.
/// See WrapEnvelope's CONTRACT note for `success` semantics.
/// </summary>
public static string WrapEnvelopeText(string message, List<CliWarning>? warnings = null, int? matched = null, bool success = true)
{
var envelope = new JsonObject
{
["success"] = success,
// BUG-R6-04: `add --json` previously emitted only `message`,
// diverging from get/set/dump which surface a `data` field.
// Keep `message` for backwards compatibility but also expose
// it under `data` so a single parser (`.data`) works across
// every command's --json output.
["data"] = message,
["message"] = message
};
if (matched.HasValue)
envelope["matched"] = matched.Value;
if (warnings is { Count: > 0 })
envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning);
return envelope.ToJsonString(JsonOptions);
}
public static string WrapEnvelopeWithData(string message, DocumentNode data, List<CliWarning>? warnings = null, int? matched = null, bool success = true)
{
var envelope = new JsonObject
{
["success"] = success,
["message"] = message,
["data"] = JsonSerializer.SerializeToNode(data, AppJsonContext.Default.DocumentNode)
};
if (matched.HasValue)
envelope["matched"] = matched.Value;
if (warnings is { Count: > 0 })
envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning);
return envelope.ToJsonString(JsonOptions);
}
/// <summary>
/// Wraps a failed text result (e.g. all properties unsupported) into an envelope.
/// Output: { "success": false, "message": "...", "warnings": [...] }
/// </summary>
public static string WrapEnvelopeError(string message, List<CliWarning>? warnings = null)
{
var envelope = new JsonObject
{
["success"] = false,
["message"] = message
};
if (warnings is { Count: > 0 })
envelope["warnings"] = JsonSerializer.SerializeToNode(warnings, AppJsonContext.Default.ListCliWarning);
return envelope.ToJsonString(JsonOptions);
}
/// <summary>
/// Wraps an error into an envelope.
/// Output: { "success": false, "error": { ... } }
/// </summary>
public static string WrapErrorEnvelope(Exception ex)
{
var errorResult = BuildErrorResult(ex);
var envelope = new JsonObject
{
["success"] = false,
["error"] = JsonSerializer.SerializeToNode(errorResult, AppJsonContext.Default.ErrorResult)
};
return envelope.ToJsonString(JsonOptions);
}
public static string FormatError(Exception ex)
{
return JsonSerializer.Serialize(BuildErrorResult(ex), AppJsonContext.Default.ErrorResult);
}
private static ErrorResult BuildErrorResult(Exception ex)
{
var result = new ErrorResult { Error = MsysPathHint.AugmentMessage(ex.Message) };
if (ex is CliException cli)
{
result.Code = cli.Code;
result.Suggestion = cli.Suggestion;
result.Help = cli.Help;
result.ValidValues = cli.ValidValues;
}
else
{
EnrichFromMessage(result, ex);
}
return result;
}
private static void EnrichFromMessage(ErrorResult result, Exception ex)
{
var msg = ex.Message;
// Pattern: "Slide 50 not found (total: 8)" → code=not_found, suggestion about valid range
var notFoundMatch = System.Text.RegularExpressions.Regex.Match(msg, @"^(\w+)\s+(\d+)\s+not found \(total:\s*(\d+)\)");
if (notFoundMatch.Success)
{
var elementType = notFoundMatch.Groups[1].Value;
var total = int.Parse(notFoundMatch.Groups[3].Value);
result.Code = "not_found";
result.Suggestion = total == 0
? $"No {elementType} elements exist. Add one first."
: $"Valid {elementType} index range: 1-{total}";
return;
}
// Pattern: "<ElementType> <N> not found" without the (total: …) tail —
// e.g. "Paragraph 99 not found" raised by Add when the parent index
// overshoots without the handler also reporting the total. Before
// this the message fell through to the internal_error catch-all even
// though semantically the same as the (total:…) variant.
var notFoundShortMatch = System.Text.RegularExpressions.Regex.Match(msg, @"^(\w+)\s+(\d+)\s+not found$");
if (notFoundShortMatch.Success)
{
result.Code = "not_found";
return;
}
// Pattern: "Path not found: …" — generic path-resolve failure raised
// by handlers when an absolute DOM path can't be walked. Surfacing
// this as not_found instead of internal_error mirrors how every
// other missing-element error is coded.
if (msg.StartsWith("Path not found:", StringComparison.Ordinal))
{
result.Code = "not_found";
return;
}
// Pattern: "Sheet not found: <name>" — xlsx-specific not_found surface.
// Handlers throw this when a worksheet name doesn't resolve; classify
// alongside other missing-element errors instead of internal_error.
if (msg.StartsWith("Sheet not found:", StringComparison.Ordinal))
{
result.Code = "not_found";
return;
}
// Pattern: "Unknown part: X. Available: ..."
var unknownPartMatch = System.Text.RegularExpressions.Regex.Match(msg, @"Unknown part: (.+?)\. Available: (.+)");
if (unknownPartMatch.Success)
{
result.Code = "invalid_path";
result.ValidValues = unknownPartMatch.Groups[2].Value.Split(", ");
return;
}
// Pattern: "Unsupported file type: .xyz. Supported: ..."
if (msg.Contains("Unsupported file type"))
{
result.Code = "unsupported_type";
return;
}
// Pattern: "diagram type 'X' is not supported yet (currently: ...)" —
// DiagramCompiler rejects a mermaid diagram kind it can't render. It's a
// bad-input value (fix by choosing a supported type), not a handler
// crash, so it must not fall through to internal_error.
if (msg.StartsWith("diagram type '", StringComparison.Ordinal)
&& msg.Contains("is not supported yet", StringComparison.Ordinal))
{
result.Code = "unsupported_type";
return;
}
// CopyFrom (`add --from`) rejections: the source element can't be cloned
// as a direct child of the target (a bookmark half-pair, a part-scoped
// footnote/endnote/comment, equation content, or a diagram group — each
// "lives inside a paragraph / another part"). WordHandler.CopyFrom throws
// "Cannot clone '<path>': … <do X> instead." — a bad-argument value the
// caller can act on, not a handler crash.
if (msg.StartsWith("Cannot clone '", StringComparison.Ordinal)
|| msg.StartsWith("Cannot move '", StringComparison.Ordinal)
|| msg.StartsWith("Cannot swap elements", StringComparison.Ordinal))
{
result.Code = "invalid_value";
return;
}
// Diagram input-validation failures: no mermaid source supplied, or the
// source parsed to an empty graph (bare header / all statements
// malformed). These are bad-input values the caller can fix, not handler
// crashes — keep them out of internal_error. (The empty-graph case used
// to leak a raw LINQ "Sequence contains no elements".)
if (msg.StartsWith("diagram requires ", StringComparison.Ordinal)
|| msg.StartsWith("diagram has no nodes", StringComparison.Ordinal)
|| msg.StartsWith("sequence diagram has no ", StringComparison.Ordinal))
{
result.Code = "invalid_value";
return;
}
// Pattern: "Row <N> in cell reference '...' is out of valid range. …" /
// "Column '<X>' in cell reference '...' is out of range. …" —
// raised by ParseCellReference (ExcelHandler.Selector.cs) when a
// cell address overshoots Excel's XFD1048576 ceiling. The Set
// path already coerces row overflow into invalid_value via its
// "Invalid row index N." text; the Add path runs through
// ParseCellReference and previously fell through to
// internal_error. Map both shapes to invalid_value so add/set
// produce the same business code for the same overflow class.
if (msg.StartsWith("Row ", StringComparison.Ordinal)
&& msg.Contains(" in cell reference ", StringComparison.Ordinal)
&& msg.Contains(" out of ", StringComparison.Ordinal))
{
result.Code = "invalid_value";
return;
}
if (msg.StartsWith("Column '", StringComparison.Ordinal)
&& msg.Contains(" in cell reference ", StringComparison.Ordinal)
&& msg.Contains(" out of range", StringComparison.Ordinal))
{
result.Code = "invalid_value";
return;
}
// Pattern: "Cell value[ at A1] exceeds Excel's 32767-character limit
// (got N)" — EnsureCellValueLength rejects over-long cell text. It's an
// input-validation failure (the user supplied a value Excel can't store),
// not a handler crash; classify as invalid_value like the row/col overflow
// rules above, not internal_error.
if (msg.StartsWith("Cell value", StringComparison.Ordinal)
&& msg.Contains("character limit", StringComparison.Ordinal))
{
result.Code = "invalid_value";
return;
}
// Pattern: "Cell <ref> not found" — raised by RemoveCell when the
// caller targets an empty/missing cell. Symmetric with the
// existing "Path not found:" / "Sheet not found:" rules; without
// it the message fell through to internal_error and agents had
// no stable code to distinguish a missing-cell remove from a
// genuine handler crash.
if (System.Text.RegularExpressions.Regex.IsMatch(msg, @"^Cell\s+[A-Z]+\d+\s+not found"))
{
result.Code = "not_found";
return;
}
// Pattern: "<Element> index N out of range (1..M)" / "(1-M)" — raised by
// the Excel handler when an index-based element (table, pivottable,
// slicer, cf, …) overshoots the live count. The separator varies ("..",
// "-") and the phrasing is "out of range" rather than "not found", so it
// missed every not_found pattern above and fell through to
// internal_error. Semantically identical to "X N not found (total: M)":
// the element does not exist. Classify as not_found, mirroring Word/PPTX.
if (System.Text.RegularExpressions.Regex.IsMatch(msg, @"\bout of range \(\d+\s*(?:\.\.|-)\s*\d+\)"))
{
result.Code = "not_found";
return;
}
// Pattern: "<thing> not found ..." with a quoted ('X') or bracket-indexed
// ([N]) identifier and/or a parenthetical/trailing clause — e.g.
// "Named range 'X' not found (no defined names in workbook)" or
// "slicer[N] not found on sheet 'Sheet1'". The bare "<Word> <N> not found"
// rules above don't match these shapes, so they fell through to
// internal_error. Any "not found" that survived the earlier, more
// specific rules is a missing-element access — code not_found.
// Exclude FileNotFoundException, which has its own file_not_found rule
// below (its message also contains "not found").
if (ex is not FileNotFoundException && msg.Contains(" not found", StringComparison.Ordinal))
{
result.Code = "not_found";
return;
}
// Pattern: "Invalid font size: ..." / "Invalid color value: ..." / "Invalid ... value"
if (msg.StartsWith("Invalid "))
{
result.Code = "invalid_value";
// Extract "Valid values: ..." if present
var validMatch = System.Text.RegularExpressions.Regex.Match(msg, @"Valid values?:\s*(.+?)\.?$");
if (validMatch.Success)
result.ValidValues = validMatch.Groups[1].Value.Split(", ");
return;
}
// Pattern: "Unknown <thing>: ..." — handlers throw this when a token
// (chart type, geometry, anchor, …) doesn't match any known value.
// Same semantic class as "Invalid <…>" — surface invalid_value.
if (msg.StartsWith("Unknown ", StringComparison.Ordinal))
{
result.Code = "invalid_value";
var validMatch = System.Text.RegularExpressions.Regex.Match(msg, @"Valid values?:\s*(.+?)\.?$");
if (validMatch.Success)
result.ValidValues = validMatch.Groups[1].Value.Split(", ");
return;
}
// Pattern: "<Type> requires a '<prop>' property" — handler-side
// pre-condition check that a creation/Set call is missing a required
// property. Maps to missing_property like "X property is required".
if (System.Text.RegularExpressions.Regex.IsMatch(msg, @"requires a '\w+' property"))
{
result.Code = "missing_property";
return;
}
// Pattern: "<thing> already exists: <name>" — uniqueness violation
// (duplicate sheet name, defined name, etc). Distinct from
// invalid_value: the value is well-formed but collides with an
// existing entity.
if (msg.Contains("already exists", StringComparison.Ordinal))
{
result.Code = "duplicate_name";
return;
}
// Pattern: "UNSUPPORTED props: ..."
if (msg.StartsWith("UNSUPPORTED props:"))
{
result.Code = "unsupported_property";
result.Help = "officecli help <format>-set";
return;
}
// Pattern: "'X' property is required for Y type"
if (msg.Contains("property is required"))
{
result.Code = "missing_property";
return;
}
// Pattern: "File not found: ..."
if (ex is FileNotFoundException)
{
result.Code = "file_not_found";
return;
}
// Pattern: "Batch input must be a JSON array..."
if (msg.StartsWith("Batch input must be"))
{
result.Code = "invalid_input";
return;
}
// Pattern: System.Text.Json error like "'I' is an invalid start of a value..."
if (ex is System.Text.Json.JsonException)
{
result.Code = "invalid_json";
return;
}
// Pattern: "No shape found with @id=NNN" / "No <element> found with ..."
if (System.Text.RegularExpressions.Regex.IsMatch(msg, @"^No \w+ found with "))
{
result.Code = "not_found";
return;
}
// Pattern: System.Xml.XPath invalid expression — surfaces as
// XPathException with message "Expression must evaluate to a node-set."
// or similar parser-side text.
if (ex is System.Xml.XPath.XPathException
|| msg.Contains("Expression must evaluate")
|| msg.Contains("invalid token")
|| msg.Contains("invalid XPath"))
{
result.Code = "invalid_xpath";
return;
}
// Pattern: file-system IO denial / disk errors (UnauthorizedAccess,
// DirectoryNotFound, generic IOException for path-level failures).
if (ex is UnauthorizedAccessException || ex is DirectoryNotFoundException
|| ex is PathTooLongException)
{
result.Code = "io_error";
return;
}
if (ex is IOException && !(ex is FileNotFoundException))
{
result.Code = "io_error";
return;
}
// Final catch-all: every WrapEnvelopeError consumer expects a 'code'
// field for stable error routing. Unhandled exceptions previously
// produced { error: "..." } with no code, leaving agent callers to
// string-match free-form messages. internal_error mirrors the
// 'unknown business failure' bucket used by other envelope code paths.
if (string.IsNullOrEmpty(result.Code))
result.Code = "internal_error";
}
public static string FormatView(string view, string content, OutputFormat format)
{
return format switch
{
OutputFormat.Json => JsonSerializer.Serialize(new ViewResult { View = view, Content = content }, AppJsonContext.Default.ViewResult),
_ => content
};
}
public static string FormatNode(DocumentNode node, OutputFormat format)
{
if (format == OutputFormat.Json)
return JsonSerializer.Serialize(node, AppJsonContext.Default.DocumentNode);
return FormatNodeAsText(node);
}
public static string FormatNodes(List<DocumentNode> nodes, OutputFormat format)
{
if (format == OutputFormat.Json)
return JsonSerializer.Serialize(new NodesResult { Matches = nodes.Count, Results = nodes }, AppJsonContext.Default.NodesResult);
var sb = new StringBuilder();
foreach (var node in nodes)
sb.AppendLine(FormatNodeOneline(node));
return sb.ToString().TrimEnd();
}
public static string FormatIssues(List<DocumentIssue> issues, OutputFormat format)
{
if (format == OutputFormat.Json)
return JsonSerializer.Serialize(new IssuesResult { Count = issues.Count, Issues = issues }, AppJsonContext.Default.IssuesResult);
var sb = new StringBuilder();
sb.AppendLine($"Found {issues.Count} issue(s):");
sb.AppendLine();
var grouped = issues.GroupBy(i => i.Type);
foreach (var group in grouped)
{
var typeName = group.Key switch
{
IssueType.Format => "Format Issues",
IssueType.Content => "Content Issues",
IssueType.Structure => "Structure Issues",
_ => "Other"
};
sb.AppendLine($"{typeName} ({group.Count()}):");
foreach (var issue in group)
{
var severity = issue.Severity switch
{
IssueSeverity.Error => "ERROR",
IssueSeverity.Warning => "WARN",
_ => "INFO"
};
sb.AppendLine($" [{issue.Id}] {issue.Path}: {issue.Message}");
if (issue.Context != null)
sb.AppendLine($" Context: \"{issue.Context}\"");
if (issue.Suggestion != null)
sb.AppendLine($" Suggestion: {issue.Suggestion}");
}
sb.AppendLine();
}
return sb.ToString().TrimEnd();
}
private static string FormatNodeAsText(DocumentNode node)
{
var sb = new StringBuilder();
sb.AppendLine(FormatNodeOneline(node));
foreach (var child in node.Children)
sb.Append(FormatNodeAsText(child));
return sb.ToString();
}
/// <summary>
/// Single-line format: path (type) "text" children=N style=X key=val key=val ...
/// Grep-friendly: every line is a complete, self-contained record.
/// </summary>
private static string FormatNodeOneline(DocumentNode node)
{
var sb = new StringBuilder();
sb.Append($"{node.Path} ({node.Type})");
if (node.Text != null) sb.Append($" \"{node.Text.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "").Replace("\n", "\\n")}\"");
if (node.ChildCount > 0 && node.Children.Count == 0) sb.Append($" children={node.ChildCount}");
if (node.Style != null) sb.Append($" style={node.Style}");
foreach (var (key, val) in node.Format)
{
// style is already shown via node.Style; skip duplicate
if (key == "style" && node.Style != null) continue;
sb.Append($" {key}={FormatNodeValue(val)}");
}
return sb.ToString();
}
// Render a Format value for the one-line text output. Most values are
// primitives whose ToString is already correct, but some readers store
// structured values (e.g. paragraph `tabs` is a List<Dictionary>) and
// those need explicit formatting — the default ToString prints
// "System.Collections.Generic.List`1[...]" which is useless to users.
private static string FormatNodeValue(object? val)
{
if (val == null) return "";
if (val is string s) return s;
// Lower-case bool to match the canonical-value convention
// ("true"/"false"); .NET's default Boolean.ToString() returns
// "True"/"False", which leaks PascalCase into Format readbacks
// (header bold/italic, toc hyperlinks, validation flags, etc.).
if (val is bool b) return b ? "true" : "false";
if (val is System.Collections.IEnumerable e and not string)
{
var parts = new List<string>();
foreach (var item in e)
{
if (item is System.Collections.IDictionary d)
{
var kvs = new List<string>();
foreach (System.Collections.DictionaryEntry de in d)
kvs.Add($"{de.Key}={de.Value}");
parts.Add("{" + string.Join(",", kvs) + "}");
}
else
{
parts.Add(item?.ToString() ?? "");
}
}
return "[" + string.Join(",", parts) + "]";
}
return val.ToString() ?? "";
}
}
+865
View File
@@ -0,0 +1,865 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
/// <summary>
/// Shared parsing helpers for handler property values.
/// Accepts flexible user input (e.g. "true", "yes", "1", "on" for booleans;
/// "24pt" or "24" for font sizes).
/// </summary>
internal static class ParseHelpers
{
/// <summary>
/// Full SVG / CSS3 named-color set (147 names + the CSS keyword
/// <c>transparent</c>) mapped to 6-digit uppercase hex RGB. Lookup is
/// case-insensitive. <c>transparent</c> maps to <c>000000</c> here; the
/// transparent semantics (alpha = 0) are handled in the alpha-aware
/// resolver below — callers using only the RGB component get black, which
/// matches CSS's "transparent = transparent black" definition.
/// </summary>
private static readonly Dictionary<string, string> NamedColors = new(StringComparer.OrdinalIgnoreCase)
{
["aliceblue"] = "F0F8FF", ["antiquewhite"] = "FAEBD7", ["aqua"] = "00FFFF",
["aquamarine"] = "7FFFD4", ["azure"] = "F0FFFF", ["beige"] = "F5F5DC",
["bisque"] = "FFE4C4", ["black"] = "000000", ["blanchedalmond"] = "FFEBCD",
["blue"] = "0000FF", ["blueviolet"] = "8A2BE2", ["brown"] = "A52A2A",
["burlywood"] = "DEB887", ["cadetblue"] = "5F9EA0", ["chartreuse"] = "7FFF00",
["chocolate"] = "D2691E", ["coral"] = "FF7F50", ["cornflowerblue"] = "6495ED",
["cornsilk"] = "FFF8DC", ["crimson"] = "DC143C", ["cyan"] = "00FFFF",
["darkblue"] = "00008B", ["darkcyan"] = "008B8B", ["darkgoldenrod"] = "B8860B",
["darkgray"] = "A9A9A9", ["darkgrey"] = "A9A9A9", ["darkgreen"] = "006400",
["darkkhaki"] = "BDB76B", ["darkmagenta"] = "8B008B", ["darkolivegreen"] = "556B2F",
["darkorange"] = "FF8C00", ["darkorchid"] = "9932CC", ["darkred"] = "8B0000",
["darksalmon"] = "E9967A", ["darkseagreen"] = "8FBC8F", ["darkslateblue"] = "483D8B",
["darkslategray"] = "2F4F4F", ["darkslategrey"] = "2F4F4F", ["darkturquoise"] = "00CED1",
["darkviolet"] = "9400D3", ["deeppink"] = "FF1493", ["deepskyblue"] = "00BFFF",
["dimgray"] = "696969", ["dimgrey"] = "696969", ["dodgerblue"] = "1E90FF",
["firebrick"] = "B22222", ["floralwhite"] = "FFFAF0", ["forestgreen"] = "228B22",
["fuchsia"] = "FF00FF", ["gainsboro"] = "DCDCDC", ["ghostwhite"] = "F8F8FF",
["gold"] = "FFD700", ["goldenrod"] = "DAA520", ["gray"] = "808080",
["grey"] = "808080", ["green"] = "008000", ["greenyellow"] = "ADFF2F",
["honeydew"] = "F0FFF0", ["hotpink"] = "FF69B4", ["indianred"] = "CD5C5C",
["indigo"] = "4B0082", ["ivory"] = "FFFFF0", ["khaki"] = "F0E68C",
["lavender"] = "E6E6FA", ["lavenderblush"] = "FFF0F5", ["lawngreen"] = "7CFC00",
["lemonchiffon"] = "FFFACD", ["lightblue"] = "ADD8E6", ["lightcoral"] = "F08080",
["lightcyan"] = "E0FFFF", ["lightgoldenrodyellow"] = "FAFAD2", ["lightgray"] = "D3D3D3",
["lightgrey"] = "D3D3D3", ["lightgreen"] = "90EE90", ["lightpink"] = "FFB6C1",
["lightsalmon"] = "FFA07A", ["lightseagreen"] = "20B2AA", ["lightskyblue"] = "87CEFA",
["lightslategray"] = "778899", ["lightslategrey"] = "778899", ["lightsteelblue"] = "B0C4DE",
["lightyellow"] = "FFFFE0", ["lime"] = "00FF00", ["limegreen"] = "32CD32",
["linen"] = "FAF0E6", ["magenta"] = "FF00FF", ["maroon"] = "800000",
["mediumaquamarine"] = "66CDAA", ["mediumblue"] = "0000CD", ["mediumorchid"] = "BA55D3",
["mediumpurple"] = "9370DB", ["mediumseagreen"] = "3CB371", ["mediumslateblue"] = "7B68EE",
["mediumspringgreen"] = "00FA9A", ["mediumturquoise"] = "48D1CC", ["mediumvioletred"] = "C71585",
["midnightblue"] = "191970", ["mintcream"] = "F5FFFA", ["mistyrose"] = "FFE4E1",
["moccasin"] = "FFE4B5", ["navajowhite"] = "FFDEAD", ["navy"] = "000080",
["oldlace"] = "FDF5E6", ["olive"] = "808000", ["olivedrab"] = "6B8E23",
["orange"] = "FFA500", ["orangered"] = "FF4500", ["orchid"] = "DA70D6",
["palegoldenrod"] = "EEE8AA", ["palegreen"] = "98FB98", ["paleturquoise"] = "AFEEEE",
["palevioletred"] = "DB7093", ["papayawhip"] = "FFEFD5", ["peachpuff"] = "FFDAB9",
["peru"] = "CD853F", ["pink"] = "FFC0CB", ["plum"] = "DDA0DD",
["powderblue"] = "B0E0E6", ["purple"] = "800080", ["rebeccapurple"] = "663399",
["red"] = "FF0000", ["rosybrown"] = "BC8F8F", ["royalblue"] = "4169E1",
["saddlebrown"] = "8B4513", ["salmon"] = "FA8072", ["sandybrown"] = "F4A460",
["seagreen"] = "2E8B57", ["seashell"] = "FFF5EE", ["sienna"] = "A0522D",
["silver"] = "C0C0C0", ["skyblue"] = "87CEEB", ["slateblue"] = "6A5ACD",
["slategray"] = "708090", ["slategrey"] = "708090", ["snow"] = "FFFAFA",
["springgreen"] = "00FF7F", ["steelblue"] = "4682B4", ["tan"] = "D2B48C",
["teal"] = "008080", ["thistle"] = "D8BFD8", ["tomato"] = "FF6347",
["turquoise"] = "40E0D0", ["violet"] = "EE82EE", ["wheat"] = "F5DEB3",
["white"] = "FFFFFF", ["whitesmoke"] = "F5F5F5", ["yellow"] = "FFFF00",
["yellowgreen"] = "9ACD32",
// CSS keyword: transparent black (rgb=000000, alpha=00). Lookup here
// returns the RGB component only; the alpha is injected by the
// alpha-aware path in NormalizeArgbColor / SanitizeColorForOoxml.
["transparent"] = "000000",
};
/// <summary>
/// Resolve a named color (CSS keyword / OOXML a:prstClr preset that overlaps
/// the CSS set, e.g. <c>black</c>/<c>white</c>/<c>red</c>) to its 6-digit hex,
/// or <c>null</c> when the name is unknown. Used by pptx a:prstClr readback so
/// preset-color fills round-trip as concrete hex instead of being dropped.
/// </summary>
internal static string? TryGetNamedColorHex(string? name)
=> !string.IsNullOrWhiteSpace(name) && NamedColors.TryGetValue(name.Trim(), out var hex)
? hex
: null;
/// <summary>
/// Try to resolve a named color, <c>rgb()</c>, <c>rgba()</c>, <c>hsl()</c>,
/// or <c>hsla()</c> notation to a 6-digit hex RGB plus an optional alpha
/// byte. Returns <c>null</c> if the input is none of the above (callers
/// should then fall through to the bare-hex parsers).
///
/// Accepted CSS forms (case-insensitive, leading/trailing whitespace
/// tolerated):
/// rgb(r,g,b) rgba(r,g,b,a) — 0255 ints or 0100% percentages, mixable
/// rgb(r g b) rgba(r g b / a) — CSS Level 4 space-separated
/// hsl(h,s%,l%) hsla(h,s%,l%,a) — h: deg (or unitless = deg), s/l: %
/// hsl(h s% l%) hsla(h s% l% / a) — CSS Level 4 space-separated
/// Alpha forms: 0..1 float (CSS default) or 0..100% (CSS Level 4).
/// </summary>
private static (string Rgb, byte? Alpha)? TryResolveColorInput(string value)
{
var trimmed = value.Trim();
// Named color lookup. `transparent` is the only entry that carries an
// implicit alpha (= 0); everything else is opaque.
if (NamedColors.TryGetValue(trimmed, out var hex))
{
if (string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase))
return (hex, (byte)0);
return (hex, null);
}
// rgb(...) / rgba(...): three or four comma-separated OR space-
// separated components (CSS Level 4). The trailing alpha may be
// separated by `/` in the space-separated form, by `,` otherwise.
var rgbMatch = Regex.Match(trimmed,
@"^rgba?\(\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*(?:[,/]\s*([^\s,/]+)\s*)?\)$",
RegexOptions.IgnoreCase);
if (rgbMatch.Success)
{
byte r = ParseRgbComponent(rgbMatch.Groups[1].Value, value);
byte g = ParseRgbComponent(rgbMatch.Groups[2].Value, value);
byte b = ParseRgbComponent(rgbMatch.Groups[3].Value, value);
byte? a = rgbMatch.Groups[4].Success
? ParseAlphaComponent(rgbMatch.Groups[4].Value, value)
: (byte?)null;
return ($"{r:X2}{g:X2}{b:X2}", a);
}
// hsl(...) / hsla(...). Hue: number (treated as degrees). Saturation
// / lightness: percentages. Alpha: 0..1 or 0..100%.
var hslMatch = Regex.Match(trimmed,
@"^hsla?\(\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*(?:[,/]\s*([^\s,/]+)\s*)?\)$",
RegexOptions.IgnoreCase);
if (hslMatch.Success)
{
double h = ParseHueDegrees(hslMatch.Groups[1].Value, value);
double s = ParsePercent01(hslMatch.Groups[2].Value, value);
double l = ParsePercent01(hslMatch.Groups[3].Value, value);
var (r, g, b) = HslToRgb(h, s, l);
byte? a = hslMatch.Groups[4].Success
? ParseAlphaComponent(hslMatch.Groups[4].Value, value)
: (byte?)null;
return ($"{r:X2}{g:X2}{b:X2}", a);
}
return null;
}
private static byte ParseRgbComponent(string token, string original)
{
token = token.Trim();
if (token.EndsWith('%'))
{
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|| pct < 0 || pct > 100)
throw new ArgumentException($"Invalid color value: '{original}'. RGB percentage components must be 0%-100%.");
return (byte)Math.Round(pct / 100.0 * 255.0);
}
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n < 0 || n > 255)
throw new ArgumentException($"Invalid color value: '{original}'. RGB components must be 0-255.");
return (byte)n;
}
private static byte ParseAlphaComponent(string token, string original)
{
token = token.Trim();
double a;
if (token.EndsWith('%'))
{
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|| pct < 0 || pct > 100)
throw new ArgumentException($"Invalid color value: '{original}'. Alpha percentage must be 0%-100%.");
a = pct / 100.0;
}
else
{
if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out a) || a < 0 || a > 1)
throw new ArgumentException($"Invalid color value: '{original}'. Alpha must be 0-1 (e.g. 0.5) or 0%-100%.");
}
return (byte)Math.Round(a * 255.0);
}
private static double ParseHueDegrees(string token, string original)
{
token = token.Trim();
// Strip an optional `deg` suffix (CSS allows it; other angle units
// — turn/rad/grad — are out of scope for the input vocabulary we care
// about and would just need their own multipliers if asked for).
if (token.EndsWith("deg", StringComparison.OrdinalIgnoreCase))
token = token[..^3].Trim();
if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var h))
throw new ArgumentException($"Invalid color value: '{original}'. Hue must be a number in degrees.");
h %= 360;
if (h < 0) h += 360;
return h;
}
private static double ParsePercent01(string token, string original)
{
token = token.Trim();
if (!token.EndsWith('%'))
throw new ArgumentException($"Invalid color value: '{original}'. HSL saturation/lightness must be expressed as a percentage (e.g. 50%).");
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|| pct < 0 || pct > 100)
throw new ArgumentException($"Invalid color value: '{original}'. HSL saturation/lightness must be 0%-100%.");
return pct / 100.0;
}
private static (byte R, byte G, byte B) HslToRgb(double h, double s, double l)
{
// Standard HSL → RGB (CSS Color Module Level 3).
double c = (1 - Math.Abs(2 * l - 1)) * s;
double hp = h / 60.0;
double x = c * (1 - Math.Abs(hp % 2 - 1));
double r1 = 0, g1 = 0, b1 = 0;
if (hp < 1) { r1 = c; g1 = x; b1 = 0; }
else if (hp < 2) { r1 = x; g1 = c; b1 = 0; }
else if (hp < 3) { r1 = 0; g1 = c; b1 = x; }
else if (hp < 4) { r1 = 0; g1 = x; b1 = c; }
else if (hp < 5) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
double m = l - c / 2;
return (
(byte)Math.Round((r1 + m) * 255),
(byte)Math.Round((g1 + m) * 255),
(byte)Math.Round((b1 + m) * 255));
}
/// <summary>
/// Format a raw hex color value for user-facing output.
/// Adds '#' prefix to 6-digit hex colors. Passes through scheme color names and special values unchanged.
/// </summary>
public static string FormatHexColor(string rawValue)
{
if (string.IsNullOrEmpty(rawValue)) return rawValue;
if (rawValue.StartsWith('#')) return rawValue.ToUpperInvariant();
if (rawValue.Length == 6 && rawValue.All(char.IsAsciiHexDigit))
return "#" + rawValue.ToUpperInvariant();
// 8-char ARGB (e.g. "FFFF0000"). When alpha == FF (fully opaque), strip the
// prefix and emit the canonical 6-digit form (#FF0000). When alpha < FF,
// preserve the 8-digit form (#80FF0000) so partial transparency survives
// round-tripping through Get. PPTX fill paths already preserve alpha via
// a:alpha; this plug closes the Excel-side gap.
if (rawValue.Length == 8 && rawValue.All(char.IsAsciiHexDigit))
{
var alpha = rawValue[..2];
if (string.Equals(alpha, "FF", StringComparison.OrdinalIgnoreCase))
return "#" + rawValue[2..].ToUpperInvariant();
// CONSISTENCY(color-input-form): emit CSS #RRGGBBAA on output when
// the value carries a hash prefix, mirroring the input form accepted
// by NormalizeArgbColor / SanitizeColorForOoxml. The internal storage
// stays AARRGGBB (OOXML convention).
return "#" + rawValue.Substring(2, 6).ToUpperInvariant() + rawValue[..2].ToUpperInvariant();
}
// Try resolving named colors (e.g. "silver" → "#C0C0C0")
var resolved = TryResolveColorInput(rawValue);
if (resolved is { } r)
return "#" + r.Rgb.ToUpperInvariant();
// Sentinel tokens are case-insensitive in OOXML but the canonical
// Format emit is lowercase — sources occasionally write `w:val="AUTO"`
// (or `"NONE"`), and a case-only delta poisons dump round-trip
// diffing. Normalise the known sentinels here; scheme color names
// (`accent1`, `dark1`, …) pass through unchanged.
if (string.Equals(rawValue, "auto", StringComparison.OrdinalIgnoreCase)
|| string.Equals(rawValue, "none", StringComparison.OrdinalIgnoreCase))
return rawValue.ToLowerInvariant();
return rawValue; // scheme colors ("accent1"), etc.
}
/// <summary>
/// Map Excel theme color index to a canonical scheme name.
/// OOXML theme indices: 0=lt1, 1=dk1, 2=lt2, 3=dk2, 4-9=accent1-6, 10=hlink, 11=folHlink.
/// </summary>
public static string? ExcelThemeIndexToName(uint themeIndex) => themeIndex switch
{
0 => "lt1",
1 => "dk1",
2 => "lt2",
3 => "dk2",
4 => "accent1",
5 => "accent2",
6 => "accent3",
7 => "accent4",
8 => "accent5",
9 => "accent6",
10 => "hlink",
11 => "folHlink",
_ => null,
};
/// <summary>
/// Returns true if the value is a recognized boolean string and is truthy.
/// Returns false for null, empty, or recognized falsy values ("false", "0", "no", "off").
/// Throws <see cref="ArgumentException"/> for non-null values that are not recognized boolean strings.
/// </summary>
public static bool IsTruthy(string? value)
{
if (value == null) return false;
return TrimInvisible(value).ToLowerInvariant() switch
{
"true" or "1" or "yes" or "on" => true,
"false" or "0" or "no" or "off" or "" => false,
_ => throw new ArgumentException(
$"Invalid boolean value: '{value}'. Expected true/false, yes/no, 1/0, or on/off.")
};
}
// R10: BOM (U+FEFF) and other zero-width / format chars are NOT in
// char.IsWhiteSpace, so a plain Trim() leaves them in place. R8 added
// Trim() but tests with `"true"` still threw. Use a stricter
// predicate that also drops format/control chars.
private static string TrimInvisible(string s)
{
return s.Trim().Trim(s_invisibleChars);
}
private static readonly char[] s_invisibleChars =
{
'', // BOM / zero-width no-break space
'', // zero-width space
'', // zero-width non-joiner
'', // zero-width joiner
'', // word joiner
' ', // non-breaking space (technically whitespace category in some configs but be explicit)
};
/// <summary>
/// Returns true if the value is a recognized truthy string.
/// Returns false for anything else (null, empty, falsy, or unrecognized values).
/// Unlike <see cref="IsTruthy"/>, never throws.
/// </summary>
public static bool IsTruthySafe(string? value)
{
if (value == null) return false;
return TrimInvisible(value).ToLowerInvariant() is "true" or "1" or "yes" or "on";
}
/// <summary>
/// Returns true if the value is a recognized boolean string (truthy or falsy).
/// Returns false for null, empty, or non-boolean values (no exception thrown).
/// </summary>
public static bool IsValidBooleanString(string? value) =>
value != null && TrimInvisible(value).ToLowerInvariant() is "true" or "1" or "yes" or "on"
or "false" or "0" or "no" or "off";
/// <summary>
/// Parse a font size string, stripping optional "pt" suffix.
/// Supports integers and fractional values (e.g. "24", "10.5", "24pt").
/// Returns double to preserve fractional sizes for correct unit conversion.
/// </summary>
public static double ParseFontSize(string value)
{
var trimmed = value.Trim();
if (trimmed.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
trimmed = trimmed[..^2].Trim();
if (trimmed.Contains(','))
throw new ArgumentException($"Invalid font size: '{value}'. Comma is not allowed — use '.' as decimal separator (e.g., '10.5').");
if (!double.TryParse(trimmed, CultureInfo.InvariantCulture, out var result) || double.IsNaN(result) || double.IsInfinity(result))
throw new ArgumentException($"Invalid font size: '{value}'. Expected a finite number (e.g., '12', '10.5', '14pt').");
if (result <= 0)
throw new ArgumentException($"Invalid font size: '{value}'. Font size must be greater than 0.");
// OOXML w:sz/w:szCs/w:fontSize are half-points and must be >= 1.
// Anything below 0.5pt would round to val=0 on write, producing
// schema-invalid OOXML. Reject up front with the same shape as
// the "<= 0" guard above.
if (result < 0.5)
throw new ArgumentException($"Invalid font size: '{value}'. Minimum font size is 0.5pt (one half-point).");
// OOXML caps user-entered font size at 1638pt (Word) and Office
// renderers stop honoring values past ~4000pt anyway. Anything
// larger silently overflows the int32 the writers cast to (PPTX
// writes pt × 100, Word writes pt × 2 as half-points), producing
// negative w:sz / a:rPr@sz values Word rejects on open. Reject
// up front with the same shape as the lower-bound guards.
if (result > 4000)
throw new ArgumentException($"Invalid font size: '{value}'. Maximum font size is 4000pt (Office cap).");
return result;
}
/// <summary>
/// BUG-R4B(BUG1): Leniently parse an integer-valued OOXML attribute that
/// some producers emit with a fractional part (e.g. <c>w:w="0.0"</c> /
/// <c>w:w="9440.0"</c>). The Open XML SDK's typed accessors (e.g.
/// <c>Int32Value.Value</c>) parse the raw string lazily and throw a bare
/// <see cref="FormatException"/> ("The input string '0.0' was not in a
/// correct format") the first time the value is read. Reading the raw
/// <c>InnerText</c> and routing it through this helper truncates the
/// fractional part to an int (matching Word's own tolerance for these
/// attributes), so dump/get no longer crash on such files.
///
/// Returns null when <paramref name="raw"/> is null/blank or cannot be
/// parsed even leniently.
/// </summary>
public static int? LenientInt(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
raw = raw.Trim();
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
return i;
// Accept a decimal string ("0.0", "9440.0", "12.5") and truncate.
if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
&& !double.IsNaN(d) && !double.IsInfinity(d)
&& d >= int.MinValue && d <= int.MaxValue)
return (int)d;
return null;
}
/// <summary>
/// Safely parse a string as int, throwing ArgumentException with a clear message on failure.
/// </summary>
public static int SafeParseInt(string value, string propertyName)
{
if (!int.TryParse(value, CultureInfo.InvariantCulture, out var result))
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected an integer.");
return result;
}
/// <summary>
/// Parse a "start:end" character-range spec into 0-based, half-open offsets.
/// Colon separator mirrors the officecli range convention (Excel A1:B2).
/// Shared by the pptx and docx run-range formatting paths so the two never
/// diverge (CONSISTENCY(char-range)).
/// </summary>
public static (int Start, int End) ParseCharRange(string spec)
{
var parts = spec.Split(':');
if (parts.Length != 2
|| !int.TryParse(parts[0].Trim(), CultureInfo.InvariantCulture, out var start)
|| !int.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out var end))
throw new ArgumentException(
$"Invalid range '{spec}'. Expected 'start:end' with 0-based integer " +
"character offsets (e.g. '6:11').");
if (start < 0 || end < 0)
throw new ArgumentException($"Invalid range '{spec}': offsets must be non-negative.");
if (end < start)
throw new ArgumentException($"Invalid range '{spec}': end ({end}) must be >= start ({start}).");
return (start, end);
}
/// <summary>
/// Parse a comma-separated list of "start:end" character ranges into 0-based,
/// half-open offset pairs (e.g. "6:11,20:25" → [(6,11),(20,25)]). A single
/// range needs no comma. This lets one range= command target several disjoint
/// spans — the same shape find's format path produces from multiple matches —
/// so range is a complete addressing alternative wherever the caller already
/// knows the offsets. Order is preserved; the caller applies them (format-only
/// run splitting does not shift character offsets, so any order is safe).
/// </summary>
public static List<(int Start, int End)> ParseCharRanges(string spec)
{
var result = new List<(int Start, int End)>();
foreach (var seg in spec.Split(','))
{
var trimmed = seg.Trim();
if (trimmed.Length == 0) continue;
result.Add(ParseCharRange(trimmed));
}
if (result.Count == 0)
throw new ArgumentException(
$"Invalid range '{spec}'. Expected one or more 'start:end' ranges " +
"(e.g. '6:11' or '6:11,20:25').");
// Normalize to ascending position order (by Start, then End) regardless of
// the order the caller listed them. This matches find, whose matches are
// inherently position-ordered, and lets a future text-mutating path process
// ranges back-to-front (descending) so earlier offsets stay valid — the same
// reason ProcessFindInParagraph iterates its matches in reverse.
result.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
return result;
}
/// <summary>
/// Safely parse a string as double, throwing ArgumentException with a clear message on failure.
/// </summary>
public static double SafeParseDouble(string value, string propertyName)
{
// NumberStyles.Float (NOT the default Float|AllowThousands) — matches every
// other numeric parser in this file. AllowThousands would silently strip a
// decimal comma ("45,5" -> 455), producing a 10x/1000x-wrong value from a
// comma-decimal-locale input instead of rejecting it.
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
|| double.IsNaN(result) || double.IsInfinity(result))
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected a finite number.");
return result;
}
/// <summary>
/// Parse a rotation value in degrees. Rejects non-finite, NaN, and values
/// outside [-3600, 3600] degrees (ten full revolutions either direction).
/// OOXML stores rotation as ST_Angle (60000ths of a degree) which fits in
/// an Int32 up to ~±35790°, but values above ~±3600° are functionally
/// indistinguishable from their modulo-360 reduction while opening the
/// door to silent overflow on the (deg * 60000) multiply. The clamp is
/// applied uniformly across pptx shape/group/connector add+set sites.
/// </summary>
public static double SafeParseRotationDegrees(string value, string propertyName)
{
var deg = SafeParseDouble(value, propertyName);
if (deg < -3600 || deg > 3600)
throw new ArgumentException($"Invalid '{propertyName}' value '{value}': degrees must be in [-3600, 3600].");
return deg;
}
/// <summary>
/// Convert a linear-gradient angle in whole degrees to OOXML
/// ST_PositiveFixedAngle units (60000ths of a degree). The raw
/// `degrees * 60000` multiply overflows Int32 for |degrees| ≳ 35792
/// (e.g. 99999° wraps to a garbage 1.7e9 angle that real Excel refuses
/// with 0x800A03EC). Reducing modulo 360 first keeps the value in the
/// spec range [0, 21600000) — geometrically identical, overflow-proof,
/// and always producing a file Excel accepts. Shared by the shape and
/// chart gradient builders so their angle handling stays consistent.
/// </summary>
public static int GradientAngleToOoxmlUnits(int degrees)
{
var normalized = ((degrees % 360) + 360) % 360;
return normalized * 60000;
}
/// <summary>
/// Safely parse a string as uint, throwing ArgumentException with a clear message on failure.
/// </summary>
public static uint SafeParseUint(string value, string propertyName)
{
if (!uint.TryParse(value, CultureInfo.InvariantCulture, out var result))
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected a non-negative integer.");
return result;
}
/// <summary>
/// Safely parse a string as byte, throwing ArgumentException with a clear message on failure.
/// </summary>
public static byte SafeParseByte(string value, string propertyName)
{
if (!byte.TryParse(value, CultureInfo.InvariantCulture, out var result))
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected an integer (0-255).");
return result;
}
/// <summary>
/// Normalize a hex color string to 8-char ARGB format (e.g. "FFFF0000").
/// Accepts: "FF0000" (6-char RGB → prepend FF), "#FF0000" (strip #), "F00" (3-char → expand),
/// "80FF0000" (8-char ARGB → as-is). Always returns uppercase.
/// </summary>
public static string NormalizeArgbColor(string value)
{
// CONSISTENCY(color-input-whitespace): outer trim BEFORE any
// hash-strip / hex inspection so " #FF0000 " / " red " / "FF0000\n"
// (CLI pipes, JSON envelopes, copy-paste) parse the same as the bare
// value. Inner whitespace ("#FF 0000") remains invalid.
var trimmedInput = value.Trim();
// Try named color / rgb()/rgba()/hsl()/hsla() first
var resolved = TryResolveColorInput(trimmedInput);
if (resolved is { } cr)
{
var alpha = cr.Alpha ?? 0xFF;
return $"{alpha:X2}{cr.Rgb}";
}
var hadHashPrefix = trimmedInput.StartsWith('#');
var hex = trimmedInput.TrimStart('#').ToUpperInvariant();
if (hex.Length == 3 && hex.All(char.IsAsciiHexDigit))
{
// Expand shorthand: "F00" → "FF0000"
hex = new string(new[] { hex[0], hex[0], hex[1], hex[1], hex[2], hex[2] });
}
else if (hadHashPrefix && hex.Length == 4 && hex.All(char.IsAsciiHexDigit)
&& hex[3] != '0')
{
// CSS #RGBA shorthand → #RRGGBBAA. The hash prefix is required
// because bare 4-hex would be ambiguous with truncated AARRGGBB
// / RRGGBB-and-a-typo. Same expansion rule as #RGB.
//
// The non-zero-alpha guard rejects #XX00 (alpha = 0, fully
// transparent): the user almost certainly mistyped a 6-digit
// RGB, not "I want an invisible color via the 4-char shorthand"
// (the explicit `transparent` keyword or 8-digit #00000000 form
// exists for that intent and is unambiguous).
hex = new string(new[]
{
hex[0], hex[0], hex[1], hex[1], hex[2], hex[2], hex[3], hex[3],
});
}
if (hex.Length == 6 && hex.All(char.IsAsciiHexDigit))
return "FF" + hex;
if (hex.Length == 8 && hex.All(char.IsAsciiHexDigit))
{
// CONSISTENCY(color-input-form): #-prefixed 8-hex is CSS RRGGBBAA
// (alpha last); bare 8-hex stays in OOXML AARRGGBB (alpha first).
// Mirrors SanitizeColorForOoxml.
if (hadHashPrefix)
return hex.Substring(6, 2) + hex[..6];
return hex;
}
throw new ArgumentException(
$"Invalid color value: '{value}'. Expected 6-digit hex RGB (e.g. FF0000), " +
$"8-digit AARRGGBB (e.g. 80FF0000), 3-digit shorthand (e.g. F00) or 4-digit #RGBA shorthand (e.g. F00A), " +
$"named color (e.g. red), rgb()/rgba()/hsl()/hsla() notation, or 'transparent'.");
}
/// <summary>
/// Word/PPT theme scheme color names (ECMA-376 §17.18.97 / §20.1.10.46).
/// Keep lowercase — input is matched case-insensitively but the canonical
/// OOXML serialization (and downstream readback) is lowercase.
/// </summary>
public static readonly HashSet<string> SchemeColorNames = new(StringComparer.OrdinalIgnoreCase)
{
"dark1", "light1", "dark2", "light2",
"accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
"hyperlink", "followedHyperlink",
// Extra variants seen in OOXML: text1/text2/background1/background2 alias dark/light.
"text1", "text2", "background1", "background2",
// BUG-R6-06: alternate Word theme color aliases (windowText / windowBackground)
// are valid OOXML w:themeColor values that map to dark1/light1.
"windowText", "windowBackground",
// BUG-R7-01: OOXML internal short forms used by PPT a:schemeClr@val.
// Accept on input so NormalizeSchemeColorName can map them back to the
// canonical user-facing names (dk1→dark1, lt1→light1, tx1→dark1, …).
"dk1", "lt1", "dk2", "lt2", "tx1", "tx2", "bg1", "bg2",
"hlink", "folHlink",
"none", "auto",
};
/// <summary>
/// True if <paramref name="value"/> is a recognized OOXML theme scheme
/// color name (e.g. "accent1", "dark2", "hyperlink"). Comparison is
/// case-insensitive; the canonical lowercase form is returned via
/// <see cref="NormalizeSchemeColorName"/>.
/// </summary>
public static bool IsSchemeColorName(string? value)
{
if (string.IsNullOrEmpty(value)) return false;
if (value!.StartsWith('#')) return false;
return SchemeColorNames.Contains(value);
}
/// <summary>
/// Returns the canonical lowercase scheme color name when
/// <paramref name="value"/> is recognized; otherwise returns null.
/// </summary>
public static string? NormalizeSchemeColorName(string? value)
{
if (!IsSchemeColorName(value)) return null;
var v = value!.ToLowerInvariant();
// Canonicalize the text/background aliases (Excel/PPTX prefer
// dark1/light1 in writes, but accept both on read).
return v switch
{
// CONSISTENCY(scheme-color-roundtrip): text1/text2/background1/background2
// are valid w:themeColor values in their own right (Word writes
// either form depending on origin app). Pass through unchanged so
// dump round-trip preserves the source's literal value. Only the
// ambiguous-alias forms (windowText / OOXML short forms) need
// canonicalisation.
"text1" => "text1",
"text2" => "text2",
"background1" => "background1",
"background2" => "background2",
"windowtext" => "dark1",
"windowbackground" => "light1",
// OOXML internal short forms (used by PPT a:schemeClr@val).
// dk/lt collapse to canonical dark/light (no separate user-facing
// form). tx/bg map to text/background — the SDK distinguishes
// SchemeColorValues.Text1 / Background1 from Dark1 / Light1, and
// the project conventions ("scheme colors pass through unchanged") demands the
// user-supplied form survives Get; collapsing tx1→dark1 would
// break that contract for text1/text2/background1/background2 set
// by the user.
"dk1" => "dark1",
"dk2" => "dark2",
"lt1" => "light1",
"lt2" => "light2",
"tx1" => "text1",
"tx2" => "text2",
"bg1" => "background1",
"bg2" => "background2",
"hlink" => "hyperlink",
"folhlink" => "followedHyperlink",
_ => v,
};
}
/// <summary>
/// Sanitize a hex color for OOXML srgbClr val (must be exactly 6-char RGB).
/// If 8-char hex is given, interprets as AARRGGBB (OOXML convention: alpha first),
/// strips the leading alpha and returns it separately.
/// Returns (rgb6, alphaPercent) where alphaPercent is 0-100000 scale or null if fully opaque.
/// </summary>
public static (string Rgb, int? AlphaPercent) SanitizeColorForOoxml(string value)
{
// CONSISTENCY(color-input-whitespace): outer trim mirrors NormalizeArgbColor.
var trimmedInput = value.Trim();
// "auto" is a legal OOXML value for shading Fill/Color — pass through unchanged
if (string.Equals(trimmedInput, "auto", StringComparison.OrdinalIgnoreCase))
return ("auto", null);
// Try named color / rgb()/rgba()/hsl()/hsla() first
var resolved = TryResolveColorInput(trimmedInput);
if (resolved is { } cr)
{
if (cr.Alpha is byte a && a != 0xFF)
return (cr.Rgb, (int)(a / 255.0 * 100000));
return (cr.Rgb, null);
}
// CONSISTENCY(color-input-form): treat the leading '#' as a signal that
// the input follows the CSS #RRGGBBAA convention (alpha last). Bare
// 8-hex (no '#') keeps the OOXML AARRGGBB convention (alpha first).
// Without this distinction, "#FFFFFFAA" was being parsed as AARRGGBB,
// silently dropping the trailing AA byte and storing rgb=FFFFAA — the
// user's RGB and alpha were both corrupted.
var hadHashPrefix = trimmedInput.StartsWith('#');
var hex = trimmedInput.TrimStart('#').ToUpperInvariant();
if (hex.Length == 8 && hex.All(char.IsAsciiHexDigit))
{
byte alphaByte;
string rgb;
if (hadHashPrefix)
{
// CSS #RRGGBBAA — alpha is the trailing pair
rgb = hex[..6];
alphaByte = Convert.ToByte(hex.Substring(6, 2), 16);
}
else
{
// OOXML AARRGGBB — alpha is the leading pair
alphaByte = Convert.ToByte(hex[..2], 16);
rgb = hex[2..];
}
if (alphaByte == 0xFF)
return (rgb, null);
var alphaPercent = (int)(alphaByte / 255.0 * 100000);
return (rgb, alphaPercent);
}
// Validate: must be exactly 6 hex digits for srgbClr val
if (hex.Length == 3 && hex.All(char.IsAsciiHexDigit))
hex = new string(new[] { hex[0], hex[0], hex[1], hex[1], hex[2], hex[2] });
else if (hadHashPrefix && hex.Length == 4 && hex.All(char.IsAsciiHexDigit)
&& hex[3] != '0')
{
// CSS #RGBA shorthand → #RRGGBBAA; route through the 8-hex path
// above to share alpha handling. See NormalizeArgbColor for the
// non-zero-alpha-digit rationale (reject #XX00 as a typo guard).
var expanded = new string(new[]
{
hex[0], hex[0], hex[1], hex[1], hex[2], hex[2], hex[3], hex[3],
});
var rgb = expanded[..6];
var alphaByte = Convert.ToByte(expanded.Substring(6, 2), 16);
if (alphaByte == 0xFF)
return (rgb, null);
return (rgb, (int)(alphaByte / 255.0 * 100000));
}
if (hex.Length != 6 || !hex.All(char.IsAsciiHexDigit))
{
// Scheme colors (accent1, dark2, hyperlink, …) are not handled
// here — callers that support theme colors must check
// IsSchemeColorName first and route to ThemeColor. Surface a
// hint instead of advertising support we don't provide.
var schemeHint = IsSchemeColorName(trimmedInput)
? " (scheme color names like 'accent1' must be set on properties that accept theme colors)"
: "";
throw new ArgumentException(
$"Invalid color value: '{value}'. Expected 6-digit hex RGB (e.g. FF0000), " +
$"8-digit AARRGGBB (e.g. 80FF0000), 3-digit shorthand (e.g. F00) or 4-digit #RGBA shorthand (e.g. F00A), " +
$"named color (e.g. red), rgb()/rgba()/hsl()/hsla() notation, or 'transparent'." + schemeHint);
}
return (hex, null);
}
// ==================== CJK Text Width Estimation ====================
/// <summary>
/// Returns true if the character is CJK ideograph, fullwidth, or CJK punctuation.
/// These characters occupy approximately 1em width (≈ fontSize) vs ~0.55em for Latin.
/// </summary>
public static bool IsCjkOrFullWidth(char ch)
{
// CJK Unified Ideographs
if (ch >= 0x4E00 && ch <= 0x9FFF) return true;
// CJK Extension A
if (ch >= 0x3400 && ch <= 0x4DBF) return true;
// CJK Compatibility Ideographs
if (ch >= 0xF900 && ch <= 0xFAFF) return true;
// CJK Symbols and Punctuation (。、「」etc.)
if (ch >= 0x3000 && ch <= 0x303F) return true;
// Fullwidth Forms (-, -, fullwidth punctuation)
if (ch >= 0xFF01 && ch <= 0xFF60) return true;
// Halfwidth Katakana is NOT fullwidth
// Hiragana
if (ch >= 0x3040 && ch <= 0x309F) return true;
// Katakana
if (ch >= 0x30A0 && ch <= 0x30FF) return true;
// Hangul Syllables
if (ch >= 0xAC00 && ch <= 0xD7AF) return true;
// Bopomofo
if (ch >= 0x3100 && ch <= 0x312F) return true;
// Em-dash (U+2014) is fullwidth in CJK contexts
if (ch == 0x2014) return true;
return false;
}
/// <summary>
/// Estimate the visual width of a string in "character units" (Latin char = 1.0, CJK/fullwidth = ~1.82).
/// Useful for Excel column auto-fit where width is measured in character units.
/// </summary>
public static double EstimateTextWidthInChars(string text)
{
double width = 0;
foreach (char ch in text)
width += IsCjkOrFullWidth(ch) ? 1.82 : 1.0;
return width;
}
/// <summary>
/// Reject XML 1.0 illegal characters before they reach the OOXML
/// serializer. Without this, the resident process accepts the value into
/// the in-memory DOM and only fails at close-time with "save failed —
/// data may be lost", losing the user's work.
///
/// XML 1.0 §2.2 Char := #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
/// Rejected: 0x000x08, 0x0B, 0x0C, 0x0E0x1F, lone UTF-16 surrogates
/// (U+D800U+DFFF without a matching pair), and the U+FFFE / U+FFFF
/// noncharacters.
/// </summary>
public static void ValidateXmlText(string? value, string propName)
{
if (value == null) return;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '\t' || c == '\n' || c == '\r') continue;
if (c < 0x20)
throw new ArgumentException(
$"{propName} contains XML-illegal control character U+{(int)c:X4} at position {i}. " +
"Allowed control chars: \\t, \\n, \\r.");
// UTF-16 surrogates only valid in pairs (high then low). A lone
// half is illegal in XML 1.0 character data.
if (char.IsHighSurrogate(c))
{
if (i + 1 >= value.Length || !char.IsLowSurrogate(value[i + 1]))
throw new ArgumentException(
$"{propName} contains an unpaired high surrogate U+{(int)c:X4} at position {i}. Use a complete UTF-16 surrogate pair.");
i++; // skip the matched low surrogate
continue;
}
if (char.IsLowSurrogate(c))
throw new ArgumentException(
$"{propName} contains an unpaired low surrogate U+{(int)c:X4} at position {i}. Use a complete UTF-16 surrogate pair.");
if (c == 0xFFFE || c == 0xFFFF)
throw new ArgumentException(
$"{propName} contains the XML-illegal noncharacter U+{(int)c:X4} at position {i}.");
}
}
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// Maps human-friendly path segment names to their OpenXML local names.
/// Allows paths like /body/paragraph[1] in addition to /body/p[1].
/// </summary>
internal static class PathAliases
{
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
// Word
["paragraph"] = "p",
["run"] = "r",
["table"] = "tbl",
["row"] = "tr",
["cell"] = "tc",
["hyperlink"] = "hyperlink",
// PowerPoint
["slide"] = "slide",
["shape"] = "shape",
["textbox"] = "textbox",
["picture"] = "picture",
};
/// <summary>
/// Resolve a path segment name to its canonical OpenXML local name.
/// Returns the original name if no alias is defined.
/// </summary>
public static string Resolve(string name)
=> Aliases.TryGetValue(name, out var canonical) ? canonical : name;
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
namespace OfficeCli.Core;
/// <summary>
/// The single, named boundary between the two positional bases in the codebase:
///
/// • path / xpath-index — 1-based. The <c>[N]</c> positions in a path segment
/// (<c>/slide[1]/shape[2]/paragraph[1]/run[1]</c>), XPath-style, and the
/// <c>slideIdx</c>/<c>shapeIdx</c>/<c>paraIdx</c>/… locals parsed from them.
/// • array index — 0-based. Any list/array position.
///
/// Wrap the conversion so a reader sees the boundary explicitly instead of a bare
/// <c>- 1</c> / <c>+ 1</c> whose base is implicit. Use it ONLY for a genuine
/// base conversion (indexing a list by a 1-based path position, or naming an
/// array position as a 1-based path position) — NOT for 1-based arithmetic that
/// stays in the path world (e.g. an OOXML row shift <c>rowIdx - 1</c> that means
/// "the row above", still a 1-based row number).
/// </summary>
internal static class PathIndex
{
/// <summary>1-based xpath position → 0-based array index (<c>n - 1</c>).</summary>
public static int ToArrayIndex(int xpathIndex) => xpathIndex - 1;
/// <summary>0-based array index → 1-based xpath position (<c>n + 1</c>).</summary>
public static int FromArrayIndex(int arrayIndex) => arrayIndex + 1;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More