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
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:
@@ -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;
|
||||
@@ -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 <path>`
|
||||
/// 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 <a:theme/> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <cmd>`, 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 <format> → list all elements
|
||||
/// help <format> <verb> → list elements supporting that verb
|
||||
/// help <format> <element> → full element detail
|
||||
/// help <format> <verb> <element> → verb-filtered element detail
|
||||
///
|
||||
/// The middle arg is interpreted as verb iff it matches HelpVerbs.
|
||||
/// Mirrors the actual CLI structure: `officecli <verb> <file> ...`, 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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) + "…";
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <cx:externalData r:id="rId1"/>
|
||||
/// 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><cs:section></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.25–10.");
|
||||
|
||||
// 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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (Q1–Q3), 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
@@ -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) { }
|
||||
}
|
||||
@@ -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)..]);
|
||||
}
|
||||
}
|
||||
@@ -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 (0–255).
|
||||
/// 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 0–100000 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 0–100000 units (percentage × 1000). Pass null to skip a transform.
|
||||
/// Input hex is 6-char without '#' prefix. Output includes '#' prefix (or rgba() if alpha < 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}";
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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 → Brandes–Kö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 = Brandes–Kö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;
|
||||
}
|
||||
|
||||
// ---- Brandes–Kö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><foreignObject></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
|
||||
/// <script src> (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 <pre id="mmderr">
|
||||
/// in the dumped DOM. The <pre> 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("&", "&").Replace("<", "<").Replace(">", ">")
|
||||
.Replace(""", "\"").Replace("'", "'");
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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]] >flag]
|
||||
/// edges A --> B --> C (chained), A ---|no arrow|, -.-> ==> --o --x <-->
|
||||
/// A -- text --> B (mid-text label), A -->|text| B (pipe label)
|
||||
/// A & B --> C & 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 & B` group into node ids (bracket-aware, so `&` 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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."
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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('-');
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 0–4) 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 0–4) ----
|
||||
|
||||
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 PRICE−pr.
|
||||
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 Wilson–Hilferty / 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 " | ||||