// Copyright 2026 OfficeCLI (https://OfficeCLI.AI)
// SPDX-License-Identifier: Apache-2.0
using System.Globalization;
using System.Text.RegularExpressions;
namespace OfficeCli.Core;
///
/// 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).
///
internal static class ParseHelpers
{
///
/// Full SVG / CSS3 named-color set (147 names + the CSS keyword
/// transparent) mapped to 6-digit uppercase hex RGB. Lookup is
/// case-insensitive. transparent maps to 000000 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.
///
private static readonly Dictionary 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",
};
///
/// Resolve a named color (CSS keyword / OOXML a:prstClr preset that overlaps
/// the CSS set, e.g. black/white/red) to its 6-digit hex,
/// or null when the name is unknown. Used by pptx a:prstClr readback so
/// preset-color fills round-trip as concrete hex instead of being dropped.
///
internal static string? TryGetNamedColorHex(string? name)
=> !string.IsNullOrWhiteSpace(name) && NamedColors.TryGetValue(name.Trim(), out var hex)
? hex
: null;
///
/// Try to resolve a named color, rgb(), rgba(), hsl(),
/// or hsla() notation to a 6-digit hex RGB plus an optional alpha
/// byte. Returns null 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).
///
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));
}
///
/// 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.
///
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.
}
///
/// 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.
///
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,
};
///
/// 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 for non-null values that are not recognized boolean strings.
///
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)
};
///
/// Returns true if the value is a recognized truthy string.
/// Returns false for anything else (null, empty, falsy, or unrecognized values).
/// Unlike , never throws.
///
public static bool IsTruthySafe(string? value)
{
if (value == null) return false;
return TrimInvisible(value).ToLowerInvariant() is "true" or "1" or "yes" or "on";
}
///
/// 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).
///
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";
///
/// 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.
///
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;
}
///
/// BUG-R4B(BUG1): Leniently parse an integer-valued OOXML attribute that
/// some producers emit with a fractional part (e.g. w:w="0.0" /
/// w:w="9440.0"). The Open XML SDK's typed accessors (e.g.
/// Int32Value.Value) parse the raw string lazily and throw a bare
/// ("The input string '0.0' was not in a
/// correct format") the first time the value is read. Reading the raw
/// InnerText 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 is null/blank or cannot be
/// parsed even leniently.
///
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;
}
///
/// Safely parse a string as int, throwing ArgumentException with a clear message on failure.
///
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;
}
///
/// 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)).
///
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);
}
///
/// 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).
///
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;
}
///
/// Safely parse a string as double, throwing ArgumentException with a clear message on failure.
///
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;
}
///
/// 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.
///
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;
}
///
/// 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.
///
public static int GradientAngleToOoxmlUnits(int degrees)
{
var normalized = ((degrees % 360) + 360) % 360;
return normalized * 60000;
}
///
/// Safely parse a string as uint, throwing ArgumentException with a clear message on failure.
///
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;
}
///
/// Safely parse a string as byte, throwing ArgumentException with a clear message on failure.
///
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;
}
///
/// 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.
///
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'.");
}
///
/// 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.
///
public static readonly HashSet 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",
};
///
/// True if is a recognized OOXML theme scheme
/// color name (e.g. "accent1", "dark2", "hyperlink"). Comparison is
/// case-insensitive; the canonical lowercase form is returned via
/// .
///
public static bool IsSchemeColorName(string? value)
{
if (string.IsNullOrEmpty(value)) return false;
if (value!.StartsWith('#')) return false;
return SchemeColorNames.Contains(value);
}
///
/// Returns the canonical lowercase scheme color name when
/// is recognized; otherwise returns null.
///
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,
};
}
///
/// 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.
///
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 ====================
///
/// Returns true if the character is CJK ideograph, fullwidth, or CJK punctuation.
/// These characters occupy approximately 1em width (≈ fontSize) vs ~0.55em for Latin.
///
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;
}
///
/// 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.
///
public static double EstimateTextWidthInChars(string text)
{
double width = 0;
foreach (char ch in text)
width += IsCjkOrFullWidth(ch) ? 1.82 : 1.0;
return width;
}
///
/// 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.
///
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}.");
}
}
}