8cb1f9f479
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
866 lines
43 KiB
C#
866 lines
43 KiB
C#
// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
using System.Globalization;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace OfficeCli.Core;
|
||
|
||
/// <summary>
|
||
/// Shared parsing helpers for handler property values.
|
||
/// Accepts flexible user input (e.g. "true", "yes", "1", "on" for booleans;
|
||
/// "24pt" or "24" for font sizes).
|
||
/// </summary>
|
||
internal static class ParseHelpers
|
||
{
|
||
/// <summary>
|
||
/// Full SVG / CSS3 named-color set (147 names + the CSS keyword
|
||
/// <c>transparent</c>) mapped to 6-digit uppercase hex RGB. Lookup is
|
||
/// case-insensitive. <c>transparent</c> maps to <c>000000</c> here; the
|
||
/// transparent semantics (alpha = 0) are handled in the alpha-aware
|
||
/// resolver below — callers using only the RGB component get black, which
|
||
/// matches CSS's "transparent = transparent black" definition.
|
||
/// </summary>
|
||
private static readonly Dictionary<string, string> NamedColors = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["aliceblue"] = "F0F8FF", ["antiquewhite"] = "FAEBD7", ["aqua"] = "00FFFF",
|
||
["aquamarine"] = "7FFFD4", ["azure"] = "F0FFFF", ["beige"] = "F5F5DC",
|
||
["bisque"] = "FFE4C4", ["black"] = "000000", ["blanchedalmond"] = "FFEBCD",
|
||
["blue"] = "0000FF", ["blueviolet"] = "8A2BE2", ["brown"] = "A52A2A",
|
||
["burlywood"] = "DEB887", ["cadetblue"] = "5F9EA0", ["chartreuse"] = "7FFF00",
|
||
["chocolate"] = "D2691E", ["coral"] = "FF7F50", ["cornflowerblue"] = "6495ED",
|
||
["cornsilk"] = "FFF8DC", ["crimson"] = "DC143C", ["cyan"] = "00FFFF",
|
||
["darkblue"] = "00008B", ["darkcyan"] = "008B8B", ["darkgoldenrod"] = "B8860B",
|
||
["darkgray"] = "A9A9A9", ["darkgrey"] = "A9A9A9", ["darkgreen"] = "006400",
|
||
["darkkhaki"] = "BDB76B", ["darkmagenta"] = "8B008B", ["darkolivegreen"] = "556B2F",
|
||
["darkorange"] = "FF8C00", ["darkorchid"] = "9932CC", ["darkred"] = "8B0000",
|
||
["darksalmon"] = "E9967A", ["darkseagreen"] = "8FBC8F", ["darkslateblue"] = "483D8B",
|
||
["darkslategray"] = "2F4F4F", ["darkslategrey"] = "2F4F4F", ["darkturquoise"] = "00CED1",
|
||
["darkviolet"] = "9400D3", ["deeppink"] = "FF1493", ["deepskyblue"] = "00BFFF",
|
||
["dimgray"] = "696969", ["dimgrey"] = "696969", ["dodgerblue"] = "1E90FF",
|
||
["firebrick"] = "B22222", ["floralwhite"] = "FFFAF0", ["forestgreen"] = "228B22",
|
||
["fuchsia"] = "FF00FF", ["gainsboro"] = "DCDCDC", ["ghostwhite"] = "F8F8FF",
|
||
["gold"] = "FFD700", ["goldenrod"] = "DAA520", ["gray"] = "808080",
|
||
["grey"] = "808080", ["green"] = "008000", ["greenyellow"] = "ADFF2F",
|
||
["honeydew"] = "F0FFF0", ["hotpink"] = "FF69B4", ["indianred"] = "CD5C5C",
|
||
["indigo"] = "4B0082", ["ivory"] = "FFFFF0", ["khaki"] = "F0E68C",
|
||
["lavender"] = "E6E6FA", ["lavenderblush"] = "FFF0F5", ["lawngreen"] = "7CFC00",
|
||
["lemonchiffon"] = "FFFACD", ["lightblue"] = "ADD8E6", ["lightcoral"] = "F08080",
|
||
["lightcyan"] = "E0FFFF", ["lightgoldenrodyellow"] = "FAFAD2", ["lightgray"] = "D3D3D3",
|
||
["lightgrey"] = "D3D3D3", ["lightgreen"] = "90EE90", ["lightpink"] = "FFB6C1",
|
||
["lightsalmon"] = "FFA07A", ["lightseagreen"] = "20B2AA", ["lightskyblue"] = "87CEFA",
|
||
["lightslategray"] = "778899", ["lightslategrey"] = "778899", ["lightsteelblue"] = "B0C4DE",
|
||
["lightyellow"] = "FFFFE0", ["lime"] = "00FF00", ["limegreen"] = "32CD32",
|
||
["linen"] = "FAF0E6", ["magenta"] = "FF00FF", ["maroon"] = "800000",
|
||
["mediumaquamarine"] = "66CDAA", ["mediumblue"] = "0000CD", ["mediumorchid"] = "BA55D3",
|
||
["mediumpurple"] = "9370DB", ["mediumseagreen"] = "3CB371", ["mediumslateblue"] = "7B68EE",
|
||
["mediumspringgreen"] = "00FA9A", ["mediumturquoise"] = "48D1CC", ["mediumvioletred"] = "C71585",
|
||
["midnightblue"] = "191970", ["mintcream"] = "F5FFFA", ["mistyrose"] = "FFE4E1",
|
||
["moccasin"] = "FFE4B5", ["navajowhite"] = "FFDEAD", ["navy"] = "000080",
|
||
["oldlace"] = "FDF5E6", ["olive"] = "808000", ["olivedrab"] = "6B8E23",
|
||
["orange"] = "FFA500", ["orangered"] = "FF4500", ["orchid"] = "DA70D6",
|
||
["palegoldenrod"] = "EEE8AA", ["palegreen"] = "98FB98", ["paleturquoise"] = "AFEEEE",
|
||
["palevioletred"] = "DB7093", ["papayawhip"] = "FFEFD5", ["peachpuff"] = "FFDAB9",
|
||
["peru"] = "CD853F", ["pink"] = "FFC0CB", ["plum"] = "DDA0DD",
|
||
["powderblue"] = "B0E0E6", ["purple"] = "800080", ["rebeccapurple"] = "663399",
|
||
["red"] = "FF0000", ["rosybrown"] = "BC8F8F", ["royalblue"] = "4169E1",
|
||
["saddlebrown"] = "8B4513", ["salmon"] = "FA8072", ["sandybrown"] = "F4A460",
|
||
["seagreen"] = "2E8B57", ["seashell"] = "FFF5EE", ["sienna"] = "A0522D",
|
||
["silver"] = "C0C0C0", ["skyblue"] = "87CEEB", ["slateblue"] = "6A5ACD",
|
||
["slategray"] = "708090", ["slategrey"] = "708090", ["snow"] = "FFFAFA",
|
||
["springgreen"] = "00FF7F", ["steelblue"] = "4682B4", ["tan"] = "D2B48C",
|
||
["teal"] = "008080", ["thistle"] = "D8BFD8", ["tomato"] = "FF6347",
|
||
["turquoise"] = "40E0D0", ["violet"] = "EE82EE", ["wheat"] = "F5DEB3",
|
||
["white"] = "FFFFFF", ["whitesmoke"] = "F5F5F5", ["yellow"] = "FFFF00",
|
||
["yellowgreen"] = "9ACD32",
|
||
// CSS keyword: transparent black (rgb=000000, alpha=00). Lookup here
|
||
// returns the RGB component only; the alpha is injected by the
|
||
// alpha-aware path in NormalizeArgbColor / SanitizeColorForOoxml.
|
||
["transparent"] = "000000",
|
||
};
|
||
|
||
/// <summary>
|
||
/// Resolve a named color (CSS keyword / OOXML a:prstClr preset that overlaps
|
||
/// the CSS set, e.g. <c>black</c>/<c>white</c>/<c>red</c>) to its 6-digit hex,
|
||
/// or <c>null</c> when the name is unknown. Used by pptx a:prstClr readback so
|
||
/// preset-color fills round-trip as concrete hex instead of being dropped.
|
||
/// </summary>
|
||
internal static string? TryGetNamedColorHex(string? name)
|
||
=> !string.IsNullOrWhiteSpace(name) && NamedColors.TryGetValue(name.Trim(), out var hex)
|
||
? hex
|
||
: null;
|
||
|
||
/// <summary>
|
||
/// Try to resolve a named color, <c>rgb()</c>, <c>rgba()</c>, <c>hsl()</c>,
|
||
/// or <c>hsla()</c> notation to a 6-digit hex RGB plus an optional alpha
|
||
/// byte. Returns <c>null</c> if the input is none of the above (callers
|
||
/// should then fall through to the bare-hex parsers).
|
||
///
|
||
/// Accepted CSS forms (case-insensitive, leading/trailing whitespace
|
||
/// tolerated):
|
||
/// rgb(r,g,b) rgba(r,g,b,a) — 0–255 ints or 0–100% percentages, mixable
|
||
/// rgb(r g b) rgba(r g b / a) — CSS Level 4 space-separated
|
||
/// hsl(h,s%,l%) hsla(h,s%,l%,a) — h: deg (or unitless = deg), s/l: %
|
||
/// hsl(h s% l%) hsla(h s% l% / a) — CSS Level 4 space-separated
|
||
/// Alpha forms: 0..1 float (CSS default) or 0..100% (CSS Level 4).
|
||
/// </summary>
|
||
private static (string Rgb, byte? Alpha)? TryResolveColorInput(string value)
|
||
{
|
||
var trimmed = value.Trim();
|
||
|
||
// Named color lookup. `transparent` is the only entry that carries an
|
||
// implicit alpha (= 0); everything else is opaque.
|
||
if (NamedColors.TryGetValue(trimmed, out var hex))
|
||
{
|
||
if (string.Equals(trimmed, "transparent", StringComparison.OrdinalIgnoreCase))
|
||
return (hex, (byte)0);
|
||
return (hex, null);
|
||
}
|
||
|
||
// rgb(...) / rgba(...): three or four comma-separated OR space-
|
||
// separated components (CSS Level 4). The trailing alpha may be
|
||
// separated by `/` in the space-separated form, by `,` otherwise.
|
||
var rgbMatch = Regex.Match(trimmed,
|
||
@"^rgba?\(\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*(?:[,/]\s*([^\s,/]+)\s*)?\)$",
|
||
RegexOptions.IgnoreCase);
|
||
if (rgbMatch.Success)
|
||
{
|
||
byte r = ParseRgbComponent(rgbMatch.Groups[1].Value, value);
|
||
byte g = ParseRgbComponent(rgbMatch.Groups[2].Value, value);
|
||
byte b = ParseRgbComponent(rgbMatch.Groups[3].Value, value);
|
||
byte? a = rgbMatch.Groups[4].Success
|
||
? ParseAlphaComponent(rgbMatch.Groups[4].Value, value)
|
||
: (byte?)null;
|
||
return ($"{r:X2}{g:X2}{b:X2}", a);
|
||
}
|
||
|
||
// hsl(...) / hsla(...). Hue: number (treated as degrees). Saturation
|
||
// / lightness: percentages. Alpha: 0..1 or 0..100%.
|
||
var hslMatch = Regex.Match(trimmed,
|
||
@"^hsla?\(\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*[,\s]\s*([^\s,/]+)\s*(?:[,/]\s*([^\s,/]+)\s*)?\)$",
|
||
RegexOptions.IgnoreCase);
|
||
if (hslMatch.Success)
|
||
{
|
||
double h = ParseHueDegrees(hslMatch.Groups[1].Value, value);
|
||
double s = ParsePercent01(hslMatch.Groups[2].Value, value);
|
||
double l = ParsePercent01(hslMatch.Groups[3].Value, value);
|
||
var (r, g, b) = HslToRgb(h, s, l);
|
||
byte? a = hslMatch.Groups[4].Success
|
||
? ParseAlphaComponent(hslMatch.Groups[4].Value, value)
|
||
: (byte?)null;
|
||
return ($"{r:X2}{g:X2}{b:X2}", a);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static byte ParseRgbComponent(string token, string original)
|
||
{
|
||
token = token.Trim();
|
||
if (token.EndsWith('%'))
|
||
{
|
||
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|
||
|| pct < 0 || pct > 100)
|
||
throw new ArgumentException($"Invalid color value: '{original}'. RGB percentage components must be 0%-100%.");
|
||
return (byte)Math.Round(pct / 100.0 * 255.0);
|
||
}
|
||
if (!int.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) || n < 0 || n > 255)
|
||
throw new ArgumentException($"Invalid color value: '{original}'. RGB components must be 0-255.");
|
||
return (byte)n;
|
||
}
|
||
|
||
private static byte ParseAlphaComponent(string token, string original)
|
||
{
|
||
token = token.Trim();
|
||
double a;
|
||
if (token.EndsWith('%'))
|
||
{
|
||
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|
||
|| pct < 0 || pct > 100)
|
||
throw new ArgumentException($"Invalid color value: '{original}'. Alpha percentage must be 0%-100%.");
|
||
a = pct / 100.0;
|
||
}
|
||
else
|
||
{
|
||
if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out a) || a < 0 || a > 1)
|
||
throw new ArgumentException($"Invalid color value: '{original}'. Alpha must be 0-1 (e.g. 0.5) or 0%-100%.");
|
||
}
|
||
return (byte)Math.Round(a * 255.0);
|
||
}
|
||
|
||
private static double ParseHueDegrees(string token, string original)
|
||
{
|
||
token = token.Trim();
|
||
// Strip an optional `deg` suffix (CSS allows it; other angle units
|
||
// — turn/rad/grad — are out of scope for the input vocabulary we care
|
||
// about and would just need their own multipliers if asked for).
|
||
if (token.EndsWith("deg", StringComparison.OrdinalIgnoreCase))
|
||
token = token[..^3].Trim();
|
||
if (!double.TryParse(token, NumberStyles.Float, CultureInfo.InvariantCulture, out var h))
|
||
throw new ArgumentException($"Invalid color value: '{original}'. Hue must be a number in degrees.");
|
||
h %= 360;
|
||
if (h < 0) h += 360;
|
||
return h;
|
||
}
|
||
|
||
private static double ParsePercent01(string token, string original)
|
||
{
|
||
token = token.Trim();
|
||
if (!token.EndsWith('%'))
|
||
throw new ArgumentException($"Invalid color value: '{original}'. HSL saturation/lightness must be expressed as a percentage (e.g. 50%).");
|
||
if (!double.TryParse(token[..^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var pct)
|
||
|| pct < 0 || pct > 100)
|
||
throw new ArgumentException($"Invalid color value: '{original}'. HSL saturation/lightness must be 0%-100%.");
|
||
return pct / 100.0;
|
||
}
|
||
|
||
private static (byte R, byte G, byte B) HslToRgb(double h, double s, double l)
|
||
{
|
||
// Standard HSL → RGB (CSS Color Module Level 3).
|
||
double c = (1 - Math.Abs(2 * l - 1)) * s;
|
||
double hp = h / 60.0;
|
||
double x = c * (1 - Math.Abs(hp % 2 - 1));
|
||
double r1 = 0, g1 = 0, b1 = 0;
|
||
if (hp < 1) { r1 = c; g1 = x; b1 = 0; }
|
||
else if (hp < 2) { r1 = x; g1 = c; b1 = 0; }
|
||
else if (hp < 3) { r1 = 0; g1 = c; b1 = x; }
|
||
else if (hp < 4) { r1 = 0; g1 = x; b1 = c; }
|
||
else if (hp < 5) { r1 = x; g1 = 0; b1 = c; }
|
||
else { r1 = c; g1 = 0; b1 = x; }
|
||
double m = l - c / 2;
|
||
return (
|
||
(byte)Math.Round((r1 + m) * 255),
|
||
(byte)Math.Round((g1 + m) * 255),
|
||
(byte)Math.Round((b1 + m) * 255));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Format a raw hex color value for user-facing output.
|
||
/// Adds '#' prefix to 6-digit hex colors. Passes through scheme color names and special values unchanged.
|
||
/// </summary>
|
||
public static string FormatHexColor(string rawValue)
|
||
{
|
||
if (string.IsNullOrEmpty(rawValue)) return rawValue;
|
||
if (rawValue.StartsWith('#')) return rawValue.ToUpperInvariant();
|
||
if (rawValue.Length == 6 && rawValue.All(char.IsAsciiHexDigit))
|
||
return "#" + rawValue.ToUpperInvariant();
|
||
// 8-char ARGB (e.g. "FFFF0000"). When alpha == FF (fully opaque), strip the
|
||
// prefix and emit the canonical 6-digit form (#FF0000). When alpha < FF,
|
||
// preserve the 8-digit form (#80FF0000) so partial transparency survives
|
||
// round-tripping through Get. PPTX fill paths already preserve alpha via
|
||
// a:alpha; this plug closes the Excel-side gap.
|
||
if (rawValue.Length == 8 && rawValue.All(char.IsAsciiHexDigit))
|
||
{
|
||
var alpha = rawValue[..2];
|
||
if (string.Equals(alpha, "FF", StringComparison.OrdinalIgnoreCase))
|
||
return "#" + rawValue[2..].ToUpperInvariant();
|
||
// CONSISTENCY(color-input-form): emit CSS #RRGGBBAA on output when
|
||
// the value carries a hash prefix, mirroring the input form accepted
|
||
// by NormalizeArgbColor / SanitizeColorForOoxml. The internal storage
|
||
// stays AARRGGBB (OOXML convention).
|
||
return "#" + rawValue.Substring(2, 6).ToUpperInvariant() + rawValue[..2].ToUpperInvariant();
|
||
}
|
||
// Try resolving named colors (e.g. "silver" → "#C0C0C0")
|
||
var resolved = TryResolveColorInput(rawValue);
|
||
if (resolved is { } r)
|
||
return "#" + r.Rgb.ToUpperInvariant();
|
||
// Sentinel tokens are case-insensitive in OOXML but the canonical
|
||
// Format emit is lowercase — sources occasionally write `w:val="AUTO"`
|
||
// (or `"NONE"`), and a case-only delta poisons dump round-trip
|
||
// diffing. Normalise the known sentinels here; scheme color names
|
||
// (`accent1`, `dark1`, …) pass through unchanged.
|
||
if (string.Equals(rawValue, "auto", StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(rawValue, "none", StringComparison.OrdinalIgnoreCase))
|
||
return rawValue.ToLowerInvariant();
|
||
return rawValue; // scheme colors ("accent1"), etc.
|
||
}
|
||
|
||
/// <summary>
|
||
/// Map Excel theme color index to a canonical scheme name.
|
||
/// OOXML theme indices: 0=lt1, 1=dk1, 2=lt2, 3=dk2, 4-9=accent1-6, 10=hlink, 11=folHlink.
|
||
/// </summary>
|
||
public static string? ExcelThemeIndexToName(uint themeIndex) => themeIndex switch
|
||
{
|
||
0 => "lt1",
|
||
1 => "dk1",
|
||
2 => "lt2",
|
||
3 => "dk2",
|
||
4 => "accent1",
|
||
5 => "accent2",
|
||
6 => "accent3",
|
||
7 => "accent4",
|
||
8 => "accent5",
|
||
9 => "accent6",
|
||
10 => "hlink",
|
||
11 => "folHlink",
|
||
_ => null,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Returns true if the value is a recognized boolean string and is truthy.
|
||
/// Returns false for null, empty, or recognized falsy values ("false", "0", "no", "off").
|
||
/// Throws <see cref="ArgumentException"/> for non-null values that are not recognized boolean strings.
|
||
/// </summary>
|
||
public static bool IsTruthy(string? value)
|
||
{
|
||
if (value == null) return false;
|
||
return TrimInvisible(value).ToLowerInvariant() switch
|
||
{
|
||
"true" or "1" or "yes" or "on" => true,
|
||
"false" or "0" or "no" or "off" or "" => false,
|
||
_ => throw new ArgumentException(
|
||
$"Invalid boolean value: '{value}'. Expected true/false, yes/no, 1/0, or on/off.")
|
||
};
|
||
}
|
||
|
||
// R10: BOM (U+FEFF) and other zero-width / format chars are NOT in
|
||
// char.IsWhiteSpace, so a plain Trim() leaves them in place. R8 added
|
||
// Trim() but tests with `"true"` still threw. Use a stricter
|
||
// predicate that also drops format/control chars.
|
||
private static string TrimInvisible(string s)
|
||
{
|
||
return s.Trim().Trim(s_invisibleChars);
|
||
}
|
||
|
||
private static readonly char[] s_invisibleChars =
|
||
{
|
||
'', // BOM / zero-width no-break space
|
||
'', // zero-width space
|
||
'', // zero-width non-joiner
|
||
'', // zero-width joiner
|
||
'', // word joiner
|
||
' ', // non-breaking space (technically whitespace category in some configs but be explicit)
|
||
};
|
||
|
||
/// <summary>
|
||
/// Returns true if the value is a recognized truthy string.
|
||
/// Returns false for anything else (null, empty, falsy, or unrecognized values).
|
||
/// Unlike <see cref="IsTruthy"/>, never throws.
|
||
/// </summary>
|
||
public static bool IsTruthySafe(string? value)
|
||
{
|
||
if (value == null) return false;
|
||
return TrimInvisible(value).ToLowerInvariant() is "true" or "1" or "yes" or "on";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns true if the value is a recognized boolean string (truthy or falsy).
|
||
/// Returns false for null, empty, or non-boolean values (no exception thrown).
|
||
/// </summary>
|
||
public static bool IsValidBooleanString(string? value) =>
|
||
value != null && TrimInvisible(value).ToLowerInvariant() is "true" or "1" or "yes" or "on"
|
||
or "false" or "0" or "no" or "off";
|
||
|
||
/// <summary>
|
||
/// Parse a font size string, stripping optional "pt" suffix.
|
||
/// Supports integers and fractional values (e.g. "24", "10.5", "24pt").
|
||
/// Returns double to preserve fractional sizes for correct unit conversion.
|
||
/// </summary>
|
||
public static double ParseFontSize(string value)
|
||
{
|
||
var trimmed = value.Trim();
|
||
if (trimmed.EndsWith("pt", StringComparison.OrdinalIgnoreCase))
|
||
trimmed = trimmed[..^2].Trim();
|
||
if (trimmed.Contains(','))
|
||
throw new ArgumentException($"Invalid font size: '{value}'. Comma is not allowed — use '.' as decimal separator (e.g., '10.5').");
|
||
if (!double.TryParse(trimmed, CultureInfo.InvariantCulture, out var result) || double.IsNaN(result) || double.IsInfinity(result))
|
||
throw new ArgumentException($"Invalid font size: '{value}'. Expected a finite number (e.g., '12', '10.5', '14pt').");
|
||
if (result <= 0)
|
||
throw new ArgumentException($"Invalid font size: '{value}'. Font size must be greater than 0.");
|
||
// OOXML w:sz/w:szCs/w:fontSize are half-points and must be >= 1.
|
||
// Anything below 0.5pt would round to val=0 on write, producing
|
||
// schema-invalid OOXML. Reject up front with the same shape as
|
||
// the "<= 0" guard above.
|
||
if (result < 0.5)
|
||
throw new ArgumentException($"Invalid font size: '{value}'. Minimum font size is 0.5pt (one half-point).");
|
||
// OOXML caps user-entered font size at 1638pt (Word) and Office
|
||
// renderers stop honoring values past ~4000pt anyway. Anything
|
||
// larger silently overflows the int32 the writers cast to (PPTX
|
||
// writes pt × 100, Word writes pt × 2 as half-points), producing
|
||
// negative w:sz / a:rPr@sz values Word rejects on open. Reject
|
||
// up front with the same shape as the lower-bound guards.
|
||
if (result > 4000)
|
||
throw new ArgumentException($"Invalid font size: '{value}'. Maximum font size is 4000pt (Office cap).");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// BUG-R4B(BUG1): Leniently parse an integer-valued OOXML attribute that
|
||
/// some producers emit with a fractional part (e.g. <c>w:w="0.0"</c> /
|
||
/// <c>w:w="9440.0"</c>). The Open XML SDK's typed accessors (e.g.
|
||
/// <c>Int32Value.Value</c>) parse the raw string lazily and throw a bare
|
||
/// <see cref="FormatException"/> ("The input string '0.0' was not in a
|
||
/// correct format") the first time the value is read. Reading the raw
|
||
/// <c>InnerText</c> and routing it through this helper truncates the
|
||
/// fractional part to an int (matching Word's own tolerance for these
|
||
/// attributes), so dump/get no longer crash on such files.
|
||
///
|
||
/// Returns null when <paramref name="raw"/> is null/blank or cannot be
|
||
/// parsed even leniently.
|
||
/// </summary>
|
||
public static int? LenientInt(string? raw)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||
raw = raw.Trim();
|
||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i))
|
||
return i;
|
||
// Accept a decimal string ("0.0", "9440.0", "12.5") and truncate.
|
||
if (double.TryParse(raw, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)
|
||
&& !double.IsNaN(d) && !double.IsInfinity(d)
|
||
&& d >= int.MinValue && d <= int.MaxValue)
|
||
return (int)d;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Safely parse a string as int, throwing ArgumentException with a clear message on failure.
|
||
/// </summary>
|
||
public static int SafeParseInt(string value, string propertyName)
|
||
{
|
||
if (!int.TryParse(value, CultureInfo.InvariantCulture, out var result))
|
||
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected an integer.");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parse a "start:end" character-range spec into 0-based, half-open offsets.
|
||
/// Colon separator mirrors the officecli range convention (Excel A1:B2).
|
||
/// Shared by the pptx and docx run-range formatting paths so the two never
|
||
/// diverge (CONSISTENCY(char-range)).
|
||
/// </summary>
|
||
public static (int Start, int End) ParseCharRange(string spec)
|
||
{
|
||
var parts = spec.Split(':');
|
||
if (parts.Length != 2
|
||
|| !int.TryParse(parts[0].Trim(), CultureInfo.InvariantCulture, out var start)
|
||
|| !int.TryParse(parts[1].Trim(), CultureInfo.InvariantCulture, out var end))
|
||
throw new ArgumentException(
|
||
$"Invalid range '{spec}'. Expected 'start:end' with 0-based integer " +
|
||
"character offsets (e.g. '6:11').");
|
||
if (start < 0 || end < 0)
|
||
throw new ArgumentException($"Invalid range '{spec}': offsets must be non-negative.");
|
||
if (end < start)
|
||
throw new ArgumentException($"Invalid range '{spec}': end ({end}) must be >= start ({start}).");
|
||
return (start, end);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parse a comma-separated list of "start:end" character ranges into 0-based,
|
||
/// half-open offset pairs (e.g. "6:11,20:25" → [(6,11),(20,25)]). A single
|
||
/// range needs no comma. This lets one range= command target several disjoint
|
||
/// spans — the same shape find's format path produces from multiple matches —
|
||
/// so range is a complete addressing alternative wherever the caller already
|
||
/// knows the offsets. Order is preserved; the caller applies them (format-only
|
||
/// run splitting does not shift character offsets, so any order is safe).
|
||
/// </summary>
|
||
public static List<(int Start, int End)> ParseCharRanges(string spec)
|
||
{
|
||
var result = new List<(int Start, int End)>();
|
||
foreach (var seg in spec.Split(','))
|
||
{
|
||
var trimmed = seg.Trim();
|
||
if (trimmed.Length == 0) continue;
|
||
result.Add(ParseCharRange(trimmed));
|
||
}
|
||
if (result.Count == 0)
|
||
throw new ArgumentException(
|
||
$"Invalid range '{spec}'. Expected one or more 'start:end' ranges " +
|
||
"(e.g. '6:11' or '6:11,20:25').");
|
||
// Normalize to ascending position order (by Start, then End) regardless of
|
||
// the order the caller listed them. This matches find, whose matches are
|
||
// inherently position-ordered, and lets a future text-mutating path process
|
||
// ranges back-to-front (descending) so earlier offsets stay valid — the same
|
||
// reason ProcessFindInParagraph iterates its matches in reverse.
|
||
result.Sort((a, b) => a.Start != b.Start ? a.Start.CompareTo(b.Start) : a.End.CompareTo(b.End));
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Safely parse a string as double, throwing ArgumentException with a clear message on failure.
|
||
/// </summary>
|
||
public static double SafeParseDouble(string value, string propertyName)
|
||
{
|
||
// NumberStyles.Float (NOT the default Float|AllowThousands) — matches every
|
||
// other numeric parser in this file. AllowThousands would silently strip a
|
||
// decimal comma ("45,5" -> 455), producing a 10x/1000x-wrong value from a
|
||
// comma-decimal-locale input instead of rejecting it.
|
||
if (!double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)
|
||
|| double.IsNaN(result) || double.IsInfinity(result))
|
||
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected a finite number.");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parse a rotation value in degrees. Rejects non-finite, NaN, and values
|
||
/// outside [-3600, 3600] degrees (ten full revolutions either direction).
|
||
/// OOXML stores rotation as ST_Angle (60000ths of a degree) which fits in
|
||
/// an Int32 up to ~±35790°, but values above ~±3600° are functionally
|
||
/// indistinguishable from their modulo-360 reduction while opening the
|
||
/// door to silent overflow on the (deg * 60000) multiply. The clamp is
|
||
/// applied uniformly across pptx shape/group/connector add+set sites.
|
||
/// </summary>
|
||
public static double SafeParseRotationDegrees(string value, string propertyName)
|
||
{
|
||
var deg = SafeParseDouble(value, propertyName);
|
||
if (deg < -3600 || deg > 3600)
|
||
throw new ArgumentException($"Invalid '{propertyName}' value '{value}': degrees must be in [-3600, 3600].");
|
||
return deg;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convert a linear-gradient angle in whole degrees to OOXML
|
||
/// ST_PositiveFixedAngle units (60000ths of a degree). The raw
|
||
/// `degrees * 60000` multiply overflows Int32 for |degrees| ≳ 35792
|
||
/// (e.g. 99999° wraps to a garbage 1.7e9 angle that real Excel refuses
|
||
/// with 0x800A03EC). Reducing modulo 360 first keeps the value in the
|
||
/// spec range [0, 21600000) — geometrically identical, overflow-proof,
|
||
/// and always producing a file Excel accepts. Shared by the shape and
|
||
/// chart gradient builders so their angle handling stays consistent.
|
||
/// </summary>
|
||
public static int GradientAngleToOoxmlUnits(int degrees)
|
||
{
|
||
var normalized = ((degrees % 360) + 360) % 360;
|
||
return normalized * 60000;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Safely parse a string as uint, throwing ArgumentException with a clear message on failure.
|
||
/// </summary>
|
||
public static uint SafeParseUint(string value, string propertyName)
|
||
{
|
||
if (!uint.TryParse(value, CultureInfo.InvariantCulture, out var result))
|
||
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected a non-negative integer.");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Safely parse a string as byte, throwing ArgumentException with a clear message on failure.
|
||
/// </summary>
|
||
public static byte SafeParseByte(string value, string propertyName)
|
||
{
|
||
if (!byte.TryParse(value, CultureInfo.InvariantCulture, out var result))
|
||
throw new ArgumentException($"Invalid '{propertyName}' value '{value}'. Expected an integer (0-255).");
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normalize a hex color string to 8-char ARGB format (e.g. "FFFF0000").
|
||
/// Accepts: "FF0000" (6-char RGB → prepend FF), "#FF0000" (strip #), "F00" (3-char → expand),
|
||
/// "80FF0000" (8-char ARGB → as-is). Always returns uppercase.
|
||
/// </summary>
|
||
public static string NormalizeArgbColor(string value)
|
||
{
|
||
// CONSISTENCY(color-input-whitespace): outer trim BEFORE any
|
||
// hash-strip / hex inspection so " #FF0000 " / " red " / "FF0000\n"
|
||
// (CLI pipes, JSON envelopes, copy-paste) parse the same as the bare
|
||
// value. Inner whitespace ("#FF 0000") remains invalid.
|
||
var trimmedInput = value.Trim();
|
||
|
||
// Try named color / rgb()/rgba()/hsl()/hsla() first
|
||
var resolved = TryResolveColorInput(trimmedInput);
|
||
if (resolved is { } cr)
|
||
{
|
||
var alpha = cr.Alpha ?? 0xFF;
|
||
return $"{alpha:X2}{cr.Rgb}";
|
||
}
|
||
|
||
var hadHashPrefix = trimmedInput.StartsWith('#');
|
||
var hex = trimmedInput.TrimStart('#').ToUpperInvariant();
|
||
if (hex.Length == 3 && hex.All(char.IsAsciiHexDigit))
|
||
{
|
||
// Expand shorthand: "F00" → "FF0000"
|
||
hex = new string(new[] { hex[0], hex[0], hex[1], hex[1], hex[2], hex[2] });
|
||
}
|
||
else if (hadHashPrefix && hex.Length == 4 && hex.All(char.IsAsciiHexDigit)
|
||
&& hex[3] != '0')
|
||
{
|
||
// CSS #RGBA shorthand → #RRGGBBAA. The hash prefix is required
|
||
// because bare 4-hex would be ambiguous with truncated AARRGGBB
|
||
// / RRGGBB-and-a-typo. Same expansion rule as #RGB.
|
||
//
|
||
// The non-zero-alpha guard rejects #XX00 (alpha = 0, fully
|
||
// transparent): the user almost certainly mistyped a 6-digit
|
||
// RGB, not "I want an invisible color via the 4-char shorthand"
|
||
// (the explicit `transparent` keyword or 8-digit #00000000 form
|
||
// exists for that intent and is unambiguous).
|
||
hex = new string(new[]
|
||
{
|
||
hex[0], hex[0], hex[1], hex[1], hex[2], hex[2], hex[3], hex[3],
|
||
});
|
||
}
|
||
if (hex.Length == 6 && hex.All(char.IsAsciiHexDigit))
|
||
return "FF" + hex;
|
||
if (hex.Length == 8 && hex.All(char.IsAsciiHexDigit))
|
||
{
|
||
// CONSISTENCY(color-input-form): #-prefixed 8-hex is CSS RRGGBBAA
|
||
// (alpha last); bare 8-hex stays in OOXML AARRGGBB (alpha first).
|
||
// Mirrors SanitizeColorForOoxml.
|
||
if (hadHashPrefix)
|
||
return hex.Substring(6, 2) + hex[..6];
|
||
return hex;
|
||
}
|
||
throw new ArgumentException(
|
||
$"Invalid color value: '{value}'. Expected 6-digit hex RGB (e.g. FF0000), " +
|
||
$"8-digit AARRGGBB (e.g. 80FF0000), 3-digit shorthand (e.g. F00) or 4-digit #RGBA shorthand (e.g. F00A), " +
|
||
$"named color (e.g. red), rgb()/rgba()/hsl()/hsla() notation, or 'transparent'.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Word/PPT theme scheme color names (ECMA-376 §17.18.97 / §20.1.10.46).
|
||
/// Keep lowercase — input is matched case-insensitively but the canonical
|
||
/// OOXML serialization (and downstream readback) is lowercase.
|
||
/// </summary>
|
||
public static readonly HashSet<string> SchemeColorNames = new(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
"dark1", "light1", "dark2", "light2",
|
||
"accent1", "accent2", "accent3", "accent4", "accent5", "accent6",
|
||
"hyperlink", "followedHyperlink",
|
||
// Extra variants seen in OOXML: text1/text2/background1/background2 alias dark/light.
|
||
"text1", "text2", "background1", "background2",
|
||
// BUG-R6-06: alternate Word theme color aliases (windowText / windowBackground)
|
||
// are valid OOXML w:themeColor values that map to dark1/light1.
|
||
"windowText", "windowBackground",
|
||
// BUG-R7-01: OOXML internal short forms used by PPT a:schemeClr@val.
|
||
// Accept on input so NormalizeSchemeColorName can map them back to the
|
||
// canonical user-facing names (dk1→dark1, lt1→light1, tx1→dark1, …).
|
||
"dk1", "lt1", "dk2", "lt2", "tx1", "tx2", "bg1", "bg2",
|
||
"hlink", "folHlink",
|
||
"none", "auto",
|
||
};
|
||
|
||
/// <summary>
|
||
/// True if <paramref name="value"/> is a recognized OOXML theme scheme
|
||
/// color name (e.g. "accent1", "dark2", "hyperlink"). Comparison is
|
||
/// case-insensitive; the canonical lowercase form is returned via
|
||
/// <see cref="NormalizeSchemeColorName"/>.
|
||
/// </summary>
|
||
public static bool IsSchemeColorName(string? value)
|
||
{
|
||
if (string.IsNullOrEmpty(value)) return false;
|
||
if (value!.StartsWith('#')) return false;
|
||
return SchemeColorNames.Contains(value);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the canonical lowercase scheme color name when
|
||
/// <paramref name="value"/> is recognized; otherwise returns null.
|
||
/// </summary>
|
||
public static string? NormalizeSchemeColorName(string? value)
|
||
{
|
||
if (!IsSchemeColorName(value)) return null;
|
||
var v = value!.ToLowerInvariant();
|
||
// Canonicalize the text/background aliases (Excel/PPTX prefer
|
||
// dark1/light1 in writes, but accept both on read).
|
||
return v switch
|
||
{
|
||
// CONSISTENCY(scheme-color-roundtrip): text1/text2/background1/background2
|
||
// are valid w:themeColor values in their own right (Word writes
|
||
// either form depending on origin app). Pass through unchanged so
|
||
// dump round-trip preserves the source's literal value. Only the
|
||
// ambiguous-alias forms (windowText / OOXML short forms) need
|
||
// canonicalisation.
|
||
"text1" => "text1",
|
||
"text2" => "text2",
|
||
"background1" => "background1",
|
||
"background2" => "background2",
|
||
"windowtext" => "dark1",
|
||
"windowbackground" => "light1",
|
||
// OOXML internal short forms (used by PPT a:schemeClr@val).
|
||
// dk/lt collapse to canonical dark/light (no separate user-facing
|
||
// form). tx/bg map to text/background — the SDK distinguishes
|
||
// SchemeColorValues.Text1 / Background1 from Dark1 / Light1, and
|
||
// the project conventions ("scheme colors pass through unchanged") demands the
|
||
// user-supplied form survives Get; collapsing tx1→dark1 would
|
||
// break that contract for text1/text2/background1/background2 set
|
||
// by the user.
|
||
"dk1" => "dark1",
|
||
"dk2" => "dark2",
|
||
"lt1" => "light1",
|
||
"lt2" => "light2",
|
||
"tx1" => "text1",
|
||
"tx2" => "text2",
|
||
"bg1" => "background1",
|
||
"bg2" => "background2",
|
||
"hlink" => "hyperlink",
|
||
"folhlink" => "followedHyperlink",
|
||
_ => v,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sanitize a hex color for OOXML srgbClr val (must be exactly 6-char RGB).
|
||
/// If 8-char hex is given, interprets as AARRGGBB (OOXML convention: alpha first),
|
||
/// strips the leading alpha and returns it separately.
|
||
/// Returns (rgb6, alphaPercent) where alphaPercent is 0-100000 scale or null if fully opaque.
|
||
/// </summary>
|
||
public static (string Rgb, int? AlphaPercent) SanitizeColorForOoxml(string value)
|
||
{
|
||
// CONSISTENCY(color-input-whitespace): outer trim mirrors NormalizeArgbColor.
|
||
var trimmedInput = value.Trim();
|
||
|
||
// "auto" is a legal OOXML value for shading Fill/Color — pass through unchanged
|
||
if (string.Equals(trimmedInput, "auto", StringComparison.OrdinalIgnoreCase))
|
||
return ("auto", null);
|
||
|
||
// Try named color / rgb()/rgba()/hsl()/hsla() first
|
||
var resolved = TryResolveColorInput(trimmedInput);
|
||
if (resolved is { } cr)
|
||
{
|
||
if (cr.Alpha is byte a && a != 0xFF)
|
||
return (cr.Rgb, (int)(a / 255.0 * 100000));
|
||
return (cr.Rgb, null);
|
||
}
|
||
|
||
// CONSISTENCY(color-input-form): treat the leading '#' as a signal that
|
||
// the input follows the CSS #RRGGBBAA convention (alpha last). Bare
|
||
// 8-hex (no '#') keeps the OOXML AARRGGBB convention (alpha first).
|
||
// Without this distinction, "#FFFFFFAA" was being parsed as AARRGGBB,
|
||
// silently dropping the trailing AA byte and storing rgb=FFFFAA — the
|
||
// user's RGB and alpha were both corrupted.
|
||
var hadHashPrefix = trimmedInput.StartsWith('#');
|
||
var hex = trimmedInput.TrimStart('#').ToUpperInvariant();
|
||
if (hex.Length == 8 && hex.All(char.IsAsciiHexDigit))
|
||
{
|
||
byte alphaByte;
|
||
string rgb;
|
||
if (hadHashPrefix)
|
||
{
|
||
// CSS #RRGGBBAA — alpha is the trailing pair
|
||
rgb = hex[..6];
|
||
alphaByte = Convert.ToByte(hex.Substring(6, 2), 16);
|
||
}
|
||
else
|
||
{
|
||
// OOXML AARRGGBB — alpha is the leading pair
|
||
alphaByte = Convert.ToByte(hex[..2], 16);
|
||
rgb = hex[2..];
|
||
}
|
||
if (alphaByte == 0xFF)
|
||
return (rgb, null);
|
||
var alphaPercent = (int)(alphaByte / 255.0 * 100000);
|
||
return (rgb, alphaPercent);
|
||
}
|
||
// Validate: must be exactly 6 hex digits for srgbClr val
|
||
if (hex.Length == 3 && hex.All(char.IsAsciiHexDigit))
|
||
hex = new string(new[] { hex[0], hex[0], hex[1], hex[1], hex[2], hex[2] });
|
||
else if (hadHashPrefix && hex.Length == 4 && hex.All(char.IsAsciiHexDigit)
|
||
&& hex[3] != '0')
|
||
{
|
||
// CSS #RGBA shorthand → #RRGGBBAA; route through the 8-hex path
|
||
// above to share alpha handling. See NormalizeArgbColor for the
|
||
// non-zero-alpha-digit rationale (reject #XX00 as a typo guard).
|
||
var expanded = new string(new[]
|
||
{
|
||
hex[0], hex[0], hex[1], hex[1], hex[2], hex[2], hex[3], hex[3],
|
||
});
|
||
var rgb = expanded[..6];
|
||
var alphaByte = Convert.ToByte(expanded.Substring(6, 2), 16);
|
||
if (alphaByte == 0xFF)
|
||
return (rgb, null);
|
||
return (rgb, (int)(alphaByte / 255.0 * 100000));
|
||
}
|
||
|
||
if (hex.Length != 6 || !hex.All(char.IsAsciiHexDigit))
|
||
{
|
||
// Scheme colors (accent1, dark2, hyperlink, …) are not handled
|
||
// here — callers that support theme colors must check
|
||
// IsSchemeColorName first and route to ThemeColor. Surface a
|
||
// hint instead of advertising support we don't provide.
|
||
var schemeHint = IsSchemeColorName(trimmedInput)
|
||
? " (scheme color names like 'accent1' must be set on properties that accept theme colors)"
|
||
: "";
|
||
throw new ArgumentException(
|
||
$"Invalid color value: '{value}'. Expected 6-digit hex RGB (e.g. FF0000), " +
|
||
$"8-digit AARRGGBB (e.g. 80FF0000), 3-digit shorthand (e.g. F00) or 4-digit #RGBA shorthand (e.g. F00A), " +
|
||
$"named color (e.g. red), rgb()/rgba()/hsl()/hsla() notation, or 'transparent'." + schemeHint);
|
||
}
|
||
|
||
return (hex, null);
|
||
}
|
||
|
||
// ==================== CJK Text Width Estimation ====================
|
||
|
||
/// <summary>
|
||
/// Returns true if the character is CJK ideograph, fullwidth, or CJK punctuation.
|
||
/// These characters occupy approximately 1em width (≈ fontSize) vs ~0.55em for Latin.
|
||
/// </summary>
|
||
public static bool IsCjkOrFullWidth(char ch)
|
||
{
|
||
// CJK Unified Ideographs
|
||
if (ch >= 0x4E00 && ch <= 0x9FFF) return true;
|
||
// CJK Extension A
|
||
if (ch >= 0x3400 && ch <= 0x4DBF) return true;
|
||
// CJK Compatibility Ideographs
|
||
if (ch >= 0xF900 && ch <= 0xFAFF) return true;
|
||
// CJK Symbols and Punctuation (。、「」etc.)
|
||
if (ch >= 0x3000 && ch <= 0x303F) return true;
|
||
// Fullwidth Forms (A-Z, 0-9, fullwidth punctuation)
|
||
if (ch >= 0xFF01 && ch <= 0xFF60) return true;
|
||
// Halfwidth Katakana is NOT fullwidth
|
||
// Hiragana
|
||
if (ch >= 0x3040 && ch <= 0x309F) return true;
|
||
// Katakana
|
||
if (ch >= 0x30A0 && ch <= 0x30FF) return true;
|
||
// Hangul Syllables
|
||
if (ch >= 0xAC00 && ch <= 0xD7AF) return true;
|
||
// Bopomofo
|
||
if (ch >= 0x3100 && ch <= 0x312F) return true;
|
||
// Em-dash (U+2014) is fullwidth in CJK contexts
|
||
if (ch == 0x2014) return true;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Estimate the visual width of a string in "character units" (Latin char = 1.0, CJK/fullwidth = ~1.82).
|
||
/// Useful for Excel column auto-fit where width is measured in character units.
|
||
/// </summary>
|
||
public static double EstimateTextWidthInChars(string text)
|
||
{
|
||
double width = 0;
|
||
foreach (char ch in text)
|
||
width += IsCjkOrFullWidth(ch) ? 1.82 : 1.0;
|
||
return width;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reject XML 1.0 illegal characters before they reach the OOXML
|
||
/// serializer. Without this, the resident process accepts the value into
|
||
/// the in-memory DOM and only fails at close-time with "save failed —
|
||
/// data may be lost", losing the user's work.
|
||
///
|
||
/// XML 1.0 §2.2 Char := #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
|
||
/// Rejected: 0x00–0x08, 0x0B, 0x0C, 0x0E–0x1F, lone UTF-16 surrogates
|
||
/// (U+D800–U+DFFF without a matching pair), and the U+FFFE / U+FFFF
|
||
/// noncharacters.
|
||
/// </summary>
|
||
public static void ValidateXmlText(string? value, string propName)
|
||
{
|
||
if (value == null) return;
|
||
for (int i = 0; i < value.Length; i++)
|
||
{
|
||
char c = value[i];
|
||
if (c == '\t' || c == '\n' || c == '\r') continue;
|
||
if (c < 0x20)
|
||
throw new ArgumentException(
|
||
$"{propName} contains XML-illegal control character U+{(int)c:X4} at position {i}. " +
|
||
"Allowed control chars: \\t, \\n, \\r.");
|
||
// UTF-16 surrogates only valid in pairs (high then low). A lone
|
||
// half is illegal in XML 1.0 character data.
|
||
if (char.IsHighSurrogate(c))
|
||
{
|
||
if (i + 1 >= value.Length || !char.IsLowSurrogate(value[i + 1]))
|
||
throw new ArgumentException(
|
||
$"{propName} contains an unpaired high surrogate U+{(int)c:X4} at position {i}. Use a complete UTF-16 surrogate pair.");
|
||
i++; // skip the matched low surrogate
|
||
continue;
|
||
}
|
||
if (char.IsLowSurrogate(c))
|
||
throw new ArgumentException(
|
||
$"{propName} contains an unpaired low surrogate U+{(int)c:X4} at position {i}. Use a complete UTF-16 surrogate pair.");
|
||
if (c == 0xFFFE || c == 0xFFFF)
|
||
throw new ArgumentException(
|
||
$"{propName} contains the XML-illegal noncharacter U+{(int)c:X4} at position {i}.");
|
||
}
|
||
}
|
||
}
|