using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text.Json; using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using System.Windows.Shapes; using Docnet.Core; using Docnet.Core.Models; using Microsoft.Win32; using PdfSharpCore.Drawing; using PdfSharpCore.Pdf; using PdfSharpCore.Pdf.IO; using MmdPdf.Services; using PdfPigDoc = UglyToad.PdfPig.PdfDocument; namespace MmdPdf.Controls { // Moved from Shell/Forms.cs; the namespace and class line are the only changes. Window members // spelled bare here resolve through PdfViewer.Bridge.cs. public partial class PdfViewer { private readonly record struct FormFieldInfo( int ObjNum, // widget annotation object number (used as key) string FieldType, // /Tx, /Btn, /Ch bool IsCheckBox, bool IsRadio, bool IsMultiLine, // /Tx with Multiline flag (bit 12) string FieldName, string CurrentValue, string OnValue, // radio/checkbox on-state value (e.g. "/Yes") bool IsReadOnly, double Cx, double Cy, double Cw, double Ch, List Options, double DaFontPt, // font size from the field's /DA (points); 0 = auto-size double Scale, // canvas units per PDF point, for converting DaFontPt to canvas size bool IsComb, // #158: /Tx with the Comb flag (bit 25) and a MaxLen int MaxLen); // #158: comb cell count (also the input length cap) /// /// Scans the current page's /Annots for Widget subtypes and overlays interactive /// WPF controls on the annotation canvas so the user can fill in form fields. /// private void RenderFormFields(int pageIndex, int canvasW, int canvasH) { if (_doc is null || _currentFile is null) return; if (pageIndex >= _doc.PageCount) return; // Render onto the page's OWN surface: the per-page overlay used by continuous / grid / // two-page views, or the single-page canvas otherwise. Previously this always used the // single-page canvas, so interactive fields only appeared in Single Page view. var canvas = CanvasForPage(pageIndex); // Remove stale overlays without wiping the entire canvas. for (int i = canvas.Children.Count - 1; i >= 0; i--) if (canvas.Children[i] is FrameworkElement fe && fe.Tag as string == FormOverlayTag) canvas.Children.RemoveAt(i); var fields = GetPageFormFields(pageIndex, canvasW, canvasH); if (fields.Count == 0) return; // Focus highlight (accent). Fields are NOT outlined at rest - the page's own field boxes // already show where to type - so we only tint a faint fill and show the accent on focus, // matching how Chrome/Brave render fields instead of drawing a green line around each one. var fieldBorder = new SolidColorBrush(Color.FromArgb(0x55, 0x88, 0x88, 0x88)); // faint gray, check/radio only var darkBrush = new SolidColorBrush(Color.FromRgb(0x22, 0x22, 0x22)); var fieldBg = new SolidColorBrush(Color.FromArgb(200, 255, 253, 231)); // Collect radio buttons per group so we can wire mutual exclusion after the loop. var radioGroups = new Dictionary>(); bool anyField = false; foreach (var f in fields) { UIElement? ctrl = null; // Text field var fillRole = ClassifyFormField(f); if (fillRole == FormFillRole.Signature || fillRole == FormFillRole.Initials) { ctrl = BuildSignZone(f, fillRole == FormFillRole.Initials, pageIndex); } else if (!f.IsCheckBox && !f.IsRadio && f.FieldType != "/Ch") { string cur = _formTextValues.TryGetValue(f.ObjNum, out var tv) ? tv : f.CurrentValue; // Size text the way the field intends: use its /DA font size when one is given; // otherwise auto-size - single-line fits the box height (capped so a tall field // isn't giant), multi-line uses a steady readable size rather than shrinking with // the box. This replaces the old box-height guess that made fields huge or tiny. double fontSize; if (_formFontSizes.TryGetValue(f.ObjNum, out var userPt) && userPt > 0 && f.Scale > 0) fontSize = userPt * f.Scale; // user override (the new per-field size control) else if (f.DaFontPt > 0.5 && f.Scale > 0) fontSize = f.DaFontPt * f.Scale; else if (f.IsMultiLine) fontSize = f.Scale > 0 ? 11.5 * f.Scale : Math.Max(11, Math.Min(f.Cw, f.Ch) * 0.5); else fontSize = f.Scale > 0 ? Math.Min(f.Ch * 0.62, 15 * f.Scale) : f.Ch * 0.62; fontSize = Math.Max(9, Math.Min(fontSize, 400)); if (f.IsComb) fontSize = Math.Max(9, Math.Min(fontSize, (f.Cw / f.MaxLen) / 0.55)); // #158: a comb field types one character per printed cell. The overlay // approximates the cell walk with a monospace face sized to the cell width // (Consolas advance is ~0.55em), capped by MaxLen; the SAVED appearance // stream places each character exactly at its cell center. double combCellW = f.IsComb ? f.Cw / f.MaxLen : 0; var tb = new TextBox { Tag = FormOverlayTag, Width = f.Cw, Height = f.Ch, Text = cur, MaxLength = f.IsComb ? f.MaxLen : 0, IsReadOnly = f.IsReadOnly, AcceptsReturn = f.IsMultiLine, TextWrapping = f.IsMultiLine ? TextWrapping.Wrap : TextWrapping.NoWrap, VerticalScrollBarVisibility = f.IsMultiLine ? ScrollBarVisibility.Auto : ScrollBarVisibility.Hidden, // A comb field is laid over the PDF's printed cells. An opaque live-field // fill hides those dividers and makes it look like an ordinary text box. // Keep the overlay transparent so the cells remain visible while the // editable characters, selection, and caret stay above the page artwork. Background = f.IsComb ? Brushes.Transparent : fieldBg, Foreground = Brushes.Black, CaretBrush = Brushes.Black, SelectionBrush = (System.Windows.Media.Brush)FindResource("HeaderLineBrush"), Style = (Style)FindResource("FormFieldTextBox"), BorderBrush = Brushes.Transparent, BorderThickness = new Thickness(1), FontSize = fontSize, Padding = f.IsComb ? new Thickness(Math.Max(0, combCellW / 2 - fontSize * 0.275), 0, 0, 0) : new Thickness(3, 0, 3, 0), VerticalContentAlignment = f.IsMultiLine ? VerticalAlignment.Top : VerticalAlignment.Center, ToolTip = string.IsNullOrEmpty(f.FieldName) ? null : f.FieldName, }; if (f.IsComb) tb.FontFamily = new FontFamily("Consolas"); // No outline at rest (the page already shows the field box); accent only on focus. // Focus also raises the per-field font-size stepper (and hides it on blur). int capturedKey = f.ObjNum; double capturedScale = f.Scale; tb.GotFocus += (_, _) => { tb.SetResourceReference(Control.BorderBrushProperty, "HeaderLineBrush"); ShowFormSizeBar(tb, capturedKey, capturedScale); }; tb.LostFocus += (_, _) => { tb.BorderBrush = Brushes.Transparent; HideFormSizeBar(); }; tb.TextChanged += (_, _) => { _formTextValues[capturedKey] = tb.Text; MarkDirty(true); }; ctrl = tb; } // Dropdown / choice else if (f.FieldType == "/Ch" && f.Options.Count > 0) { string cur = _formTextValues.TryGetValue(f.ObjNum, out var tv) ? tv : f.CurrentValue; var combo = new ComboBox { Tag = FormOverlayTag, Width = f.Cw, Height = f.Ch, IsEnabled = !f.IsReadOnly, Foreground = Brushes.Black, FontSize = f.DaFontPt > 0.5 && f.Scale > 0 ? f.DaFontPt * f.Scale : Math.Min(Math.Max(10, f.Ch * 0.55), 16), ToolTip = string.IsNullOrEmpty(f.FieldName) ? null : f.FieldName, }; foreach (var opt in f.Options) combo.Items.Add(opt); combo.SelectedItem = cur; int capturedKey = f.ObjNum; combo.SelectionChanged += (_, _) => { if (combo.SelectedItem is string s) { _formTextValues[capturedKey] = s; MarkDirty(true); } }; ctrl = combo; } // Checkbox else if (f.IsCheckBox) { bool isChecked = _formCheckValues.TryGetValue(f.ObjNum, out var cv) ? cv : !string.IsNullOrEmpty(f.CurrentValue) && f.CurrentValue != "/Off" && f.CurrentValue != "Off"; // Custom border-based checkbox - WPF's built-in CheckBox indicator // doesn't scale with Width/Height, so we draw it ourselves. double checkFs = Math.Min(f.Cw, f.Ch) * 0.72; var checkMark = new TextBlock { Text = "✓", FontSize = checkFs, FontWeight = FontWeights.Bold, Foreground = darkBrush, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Visibility = isChecked ? Visibility.Visible : Visibility.Collapsed, }; var box = new Border { Tag = FormOverlayTag, Width = f.Cw, Height = f.Ch, Background = fieldBg, BorderBrush = fieldBorder, BorderThickness = new Thickness(1.5), CornerRadius = new CornerRadius(2), Cursor = f.IsReadOnly ? Cursors.Arrow : Cursors.Hand, Child = checkMark, ToolTip = string.IsNullOrEmpty(f.FieldName) ? null : f.FieldName, }; if (!f.IsReadOnly) { int capturedKey = f.ObjNum; box.MouseLeftButtonDown += (_, e) => { bool now = !(_formCheckValues.TryGetValue(capturedKey, out var v) ? v : isChecked); _formCheckValues[capturedKey] = now; checkMark.Visibility = now ? Visibility.Visible : Visibility.Collapsed; MarkDirty(true); e.Handled = true; }; } ctrl = box; } // Radio button else if (f.IsRadio) { string groupSelected = _formRadioValues.TryGetValue(f.FieldName, out var rv) ? rv : f.CurrentValue; // CurrentValue = parent /V = currently selected on-value bool isSelected = groupSelected == f.OnValue; double size = Math.Min(f.Cw, f.Ch) * 0.88; double inner = size * 0.52; var dot = new Ellipse { Width = inner, Height = inner, Fill = darkBrush, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, Visibility = isSelected ? Visibility.Visible : Visibility.Collapsed, }; var ring = new Ellipse { Width = size, Height = size, Stroke = fieldBorder, StrokeThickness = 1.5, Fill = fieldBg, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, }; var grid = new Grid { Width = f.Cw, Height = f.Ch }; grid.Children.Add(ring); grid.Children.Add(dot); var radioBorder = new Border { Tag = FormOverlayTag, Width = f.Cw, Height = f.Ch, Background = Brushes.Transparent, Cursor = f.IsReadOnly ? Cursors.Arrow : Cursors.Hand, Child = grid, ToolTip = string.IsNullOrEmpty(f.FieldName) ? null : f.FieldName, }; // Register dot for mutual-exclusion wiring after the loop. if (!radioGroups.TryGetValue(f.FieldName, out var groupList)) radioGroups[f.FieldName] = groupList = []; groupList.Add((dot, f.OnValue)); if (!f.IsReadOnly) { string capturedGroup = f.FieldName; string capturedOn = f.OnValue; radioBorder.MouseLeftButtonDown += (_, e) => { _formRadioValues[capturedGroup] = capturedOn; // Deselect all in group, then select this one. if (radioGroups.TryGetValue(capturedGroup, out var gl)) foreach (var (d, ov) in gl) d.Visibility = ov == capturedOn ? Visibility.Visible : Visibility.Collapsed; MarkDirty(true); e.Handled = true; }; } ctrl = radioBorder; } if (ctrl is null) continue; Canvas.SetLeft(ctrl, f.Cx); Canvas.SetTop(ctrl, f.Cy); // #156: field overlays sit BELOW the annotation layer. RenderAllAnnotations paints // the annotations and then restores these, and a Canvas paints later children on // top - so a signature dropped on a fill-in field disappeared behind the field's // own control. Annotations render at the default ZIndex 0, so -1 puts the fields // under them without touching the annotation paths. Clicking a covered field still // works: every annotation visual is IsHitTestVisible=false, so it never swallows // the click that reaches the field beneath it. Panel.SetZIndex(ctrl, -1); canvas.Children.Add(ctrl); anyField = true; } if (anyField) SetStatus(string.Format(Loc("Str_PageFormFields"), pageIndex + 1, _doc.PageCount)); } /// /// Parses Widget annotations from the given page into field descriptors with canvas coordinates. /// Walks the parent chain for each widget to resolve inherited /FT, /T, /V, and /Ff. /// private List GetPageFormFields(int pageIndex, int canvasW, int canvasH) { var result = new List(); if (_doc is null || pageIndex >= _doc.PageCount) return result; var page = _doc.Pages[pageIndex]; // PDFium renders the CropBox (falling back to the MediaBox when there is no crop), so // field /Rect coordinates must be mapped relative to THAT box's origin and size - not // assumed to start at (0,0) with MediaBox dimensions. Pages whose box origin is offset, // or whose CropBox is inset from the MediaBox, otherwise shift every field a little; // mapping to the rendered box's own origin lines them up the way Acrobat/Chrome do. // // CRITICAL: read the boxes via the raw dictionary, NEVER via page.MediaBox/page.CropBox. // Those property getters have create-on-read semantics in PdfSharpCore: touching // page.CropBox on a page without one PLANTS an empty /CropBox [0 0 0 0] into the page // dictionary (the same lazy-getter trap as the phantom /Outlines, #103), which then // saves to disk and makes Adobe reject every page as "dimensions out-of-range". // // The entry can be a parsed PdfArray (as loaded from disk) OR a PdfRectangle: // PdfSharpCore's GetRectangle - which backs page.MediaBox / page.Width / page.Height - // converts the array to a PdfRectangle and STORES IT BACK into the dictionary // (PdfDictionary.GetRectangle: "this[key] = value"). The link layer reads // pdfPage.Width.Point on every page render, so by the time fields are parsed the array // is usually gone and a plain GetArray came back null - which dropped every non-A4 // document into the A4 fallback below and shifted all field overlays, worst near the // top of the page (found 2026-07-23 via the US Letter brochure build; the shipped A4 // brochure masked it). Handle both shapes, like ScrubDegenerateCropBoxes does, and // walk /Parent: /MediaBox and /CropBox are inheritable page-tree attributes. (double x, double y, double w, double h)? ReadBox(string key) { PdfDictionary? node = page; for (int depth = 0; node is not null && depth < 32; depth++) { PdfItem? item = node.Elements[key]; if (item is not null && item is not PdfArray && item is not PdfRectangle) item = DerefItem(item); if (item is PdfRectangle pr) return (Math.Min(pr.X1, pr.X2), Math.Min(pr.Y1, pr.Y2), Math.Abs(pr.X2 - pr.X1), Math.Abs(pr.Y2 - pr.Y1)); if (item is PdfArray { Elements.Count: 4 } a) { double x1 = a.Elements.GetReal(0), y1 = a.Elements.GetReal(1); double x2 = a.Elements.GetReal(2), y2 = a.Elements.GetReal(3); return (Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1), Math.Abs(y2 - y1)); } var parent = node.Elements["/Parent"]; node = parent is null ? null : parent as PdfDictionary ?? DerefItem(parent) as PdfDictionary; } return null; } var media = ReadBox("/MediaBox"); var crop = ReadBox("/CropBox"); var box = (crop.HasValue && crop.Value.w > 1 && crop.Value.h > 1) ? crop.Value : media ?? (x: 0.0, y: 0.0, w: 595.28, h: 841.89); double boxX = box.x; // box lower-left origin in PDF user space double boxY = box.y; double pageW = box.w > 0 ? box.w : 595.28; double pageH = box.h > 0 ? box.h : 841.89; int rotation = ((page.Rotate % 360) + 360) % 360; try { var annotsArr = page.Elements.GetArray("/Annots"); if (annotsArr is null || annotsArr.Elements.Count == 0) return result; for (int i = 0; i < annotsArr.Elements.Count; i++) { PdfItem? elem = annotsArr.Elements[i]; PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary; if (ann is null) continue; var subtype = ann.Elements["/Subtype"]?.ToString() ?? ""; if (!subtype.Contains("Widget")) continue; // Get rect var rectArr = ann.Elements.GetArray("/Rect"); if (rectArr is null || rectArr.Elements.Count < 4) continue; double rx1 = rectArr.Elements.GetReal(0); double ry1 = rectArr.Elements.GetReal(1); double rx2 = rectArr.Elements.GetReal(2); double ry2 = rectArr.Elements.GetReal(3); if (rx1 > rx2) (rx1, rx2) = (rx2, rx1); if (ry1 > ry2) (ry1, ry2) = (ry2, ry1); // Field rect relative to the rendered box's lower-left origin, so an offset // MediaBox/CropBox doesn't push the field off its drawn box. double fx1 = rx1 - boxX, fy1 = ry1 - boxY; double fx2 = rx2 - boxX, fy2 = ry2 - boxY; // Map PDF rect (bottom-left origin, unrotated) to canvas coords. // The canvas matches the Docnet-rendered bitmap which has already applied // the page rotation, so we must transform accordingly. double cx, cy, cw, ch; switch (rotation) { case 90: // 90 CW: bottom->left, left->top; canvas is pageH-wide x pageW-tall // (px,py) -> canvas (py, px) cx = fy1 / pageH * canvasW; cy = fx1 / pageW * canvasH; cw = (fy2 - fy1) / pageH * canvasW; ch = (fx2 - fx1) / pageW * canvasH; break; case 180: // 180: both axes flipped // (px,py) -> canvas (pageW-px, py) cx = (pageW - fx2) / pageW * canvasW; cy = fy1 / pageH * canvasH; cw = (fx2 - fx1) / pageW * canvasW; ch = (fy2 - fy1) / pageH * canvasH; break; case 270: // 270 CW (= 90 CCW): bottom->right, right->top; canvas is pageH-wide x pageW-tall // (px,py) -> canvas (pageH-py, pageW-px) cx = (pageH - fy2) / pageH * canvasW; cy = (pageW - fx2) / pageW * canvasH; cw = (fy2 - fy1) / pageH * canvasW; ch = (fx2 - fx1) / pageW * canvasH; break; default: // 0 - standard bottom-left PDF -> top-left canvas cx = fx1 / pageW * canvasW; cy = (pageH - fy2) / pageH * canvasH; cw = (fx2 - fx1) / pageW * canvasW; ch = (fy2 - fy1) / pageH * canvasH; break; } // A malformed widget rectangle must not reach a WPF Width or Height property. // WPF throws for NaN and infinity, which previously took down the viewer when // RenderAllAnnotations rebuilt the form overlay after a page click (#181). if (!IsFinite(cx) || !IsFinite(cy) || !IsFinitePositive(cw) || !IsFinitePositive(ch) || cw < 2 || ch < 2) continue; // Walk the parent chain to resolve inherited attributes string ft = ""; string name = ""; string curVal = ""; string da = ""; // default appearance string (holds the field's font size) int flags = 0; int maxLen = 0; // #158: /MaxLen, the comb cell count var options = new List(); PdfDictionary? node = ann; while (node is not null) { if (string.IsNullOrEmpty(ft) && node.Elements["/FT"] is not null) ft = node.Elements["/FT"]?.ToString() ?? ""; if (string.IsNullOrEmpty(name) && node.Elements["/T"] is PdfString ts) name = ts.Value; if (string.IsNullOrEmpty(curVal) && node.Elements["/V"] is not null) { var vElem = node.Elements["/V"]; curVal = vElem is PdfString vs ? vs.Value : vElem?.ToString() ?? ""; } if (string.IsNullOrEmpty(da) && node.Elements["/DA"] is PdfString das) da = das.Value; if (flags == 0 && node.Elements["/Ff"] is PdfInteger fi) flags = fi.Value; if (maxLen == 0 && node.Elements["/MaxLen"] is PdfInteger ml) maxLen = ml.Value; // #158: comb cell count (inheritable, like /Ff) if (options.Count == 0 && node.Elements.GetArray("/Opt") is PdfArray optArr) { for (int j = 0; j < optArr.Elements.Count; j++) { var o = optArr.Elements[j]; if (o is PdfString ps2) options.Add(ps2.Value); else if (o is PdfArray pa2 && pa2.Elements.Count >= 2) options.Add((pa2.Elements[1] as PdfString)?.Value ?? ""); } } // Move to parent var parentItem = node.Elements["/Parent"]; if (parentItem is null) break; node = parentItem as PdfDictionary ?? DerefItem(parentItem) as PdfDictionary; } // No resolvable field type (directly or inherited) means this Widget is not a fillable // field (just a bare annotation widget). Skip it rather than guessing it's a text box. if (string.IsNullOrEmpty(ft)) continue; bool isReadOnly = (flags & 1) != 0; bool isMultiLine = ft.Contains("Tx") && (flags & 4096) != 0; // #158: comb (bit 25, value 1<<24) - one character per evenly-spaced cell. // Only meaningful with a MaxLen; the spec makes comb exclusive of multiline. bool isComb = ft.Contains("Tx") && (flags & (1 << 24)) != 0 && maxLen > 0 && !isMultiLine; bool isPushBtn = ft.Contains("Btn") && (flags & (1 << 16)) != 0; bool isRadio = ft.Contains("Btn") && !isPushBtn && (flags & (1 << 15)) != 0; bool isCheckBox = ft.Contains("Btn") && !isPushBtn && !isRadio; // A button widget that fires an action (navigation /GoTo, /URI, JavaScript, ...) is a // pushbutton/link, not a fillable control. Some PDFs - e.g. manuals with a clickable // page index down one side - omit the pushbutton flag, which would otherwise make every // one of those render as a spurious checkbox. Treat any actioned button as a pushbutton. // (A real checkbox/radio always carries an /AS appearance state; a pushbutton does not.) if (ft.Contains("Btn") && (isPushBtn || ann.Elements["/A"] is not null || ann.Elements["/AS"] is null)) continue; // Extract the "on" value for this widget (radio/checkbox selected state). // Found in /AP /N as the key that is NOT /Off. string onValue = "/Yes"; try { var apDict = ann.Elements.GetDictionary("/AP"); var nDict = apDict?.Elements.GetDictionary("/N"); if (nDict is not null) foreach (var k in nDict.Elements.Keys) if (k != "/Off") { onValue = k; break; } } catch { } int objNum = PdfScrub.GetObjectNumber(elem); if (objNum < 0) objNum = -(pageIndex * 10000 + i); // synthetic key for inline dicts // Font size the field asks for (points) and the page's render scale, so the // overlay can size text the way the form intends rather than guessing from the // box height (which made tall fields huge and others shrink). double daFontPt = ParseDaFontSize(da); double fScale = (rotation == 90 || rotation == 270) ? canvasH / pageW : canvasH / pageH; result.Add(new FormFieldInfo(objNum, ft, isCheckBox, isRadio, isMultiLine, name, curVal, onValue, isReadOnly, cx, cy, cw, ch, options, daFontPt, fScale, isComb, maxLen)); } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GetPageFormFields: {ex}"); } return result; } // Parses the font size (points) from a PDF /DA default-appearance string, e.g. // "/Helv 11 Tf 0 g" -> 11. Returns 0 when the size is "auto" (0) or there's no Tf operator. private static double ParseDaFontSize(string da) { if (string.IsNullOrWhiteSpace(da)) return 0; var t = da.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries); for (int i = 1; i < t.Length; i++) if (t[i] == "Tf" && double.TryParse(t[i - 1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double sz) && sz > 0) return sz; return 0; } /// /// Writes all filled form values back into the PDF document's AcroForm field dictionaries. /// Called just before saving so values are persisted in the output file. /// private void WriteFormValuesToDocument() { if (_doc is null) return; if (_formTextValues.Count == 0 && _formCheckValues.Count == 0 && _formRadioValues.Count == 0) return; try { for (int p = 0; p < _doc.PageCount; p++) { var page = _doc.Pages[p]; var annotsArr = page.Elements.GetArray("/Annots"); if (annotsArr is null) continue; for (int i = 0; i < annotsArr.Elements.Count; i++) { PdfItem? elem = annotsArr.Elements[i]; PdfDictionary? ann = elem as PdfDictionary ?? DerefItem(elem) as PdfDictionary; if (ann is null) continue; var subtype = ann.Elements["/Subtype"]?.ToString() ?? ""; if (!subtype.Contains("Widget")) continue; int objNum = PdfScrub.GetObjectNumber(elem); if (objNum < 0) objNum = -(p * 10000 + i); // Walk parent chain to find the canonical field dict (owns /FT) PdfDictionary? fieldDict = ann; PdfDictionary? node = ann; while (node is not null) { if (node.Elements["/FT"] is not null) { fieldDict = node; break; } var pi = node.Elements["/Parent"]; if (pi is null) break; node = pi as PdfDictionary ?? DerefItem(pi) as PdfDictionary; } // Gather field rect for AP stream sizing var rectArr = ann.Elements.GetArray("/Rect"); double fieldW = 100, fieldH = 20; if (rectArr?.Elements.Count >= 4) { double rx1 = rectArr.Elements.GetReal(0), ry1 = rectArr.Elements.GetReal(1); double rx2 = rectArr.Elements.GetReal(2), ry2 = rectArr.Elements.GetReal(3); fieldW = Math.Abs(rx2 - rx1); fieldH = Math.Abs(ry2 - ry1); } // Resolve /DA for font name/size (walk parent chain) string? daStr = null; node = ann; while (node is not null && daStr is null) { if (node.Elements["/DA"] is PdfString ds) daStr = ds.Value; var pi = node.Elements["/Parent"]; if (pi is null) break; node = pi as PdfDictionary ?? DerefItem(pi) as PdfDictionary; } // /Ff is inheritable like /DA, and bit 13 (4096) is Multiline. The saved // appearance has to know which it is: a multiline field lays its value out // in lines from the top of the box, a single-line one draws one centered line. int fieldFlags = 0; int combLen = 0; // #158: /MaxLen, inheritable like /Ff node = ann; while (node is not null && (fieldFlags == 0 || combLen == 0)) { if (fieldFlags == 0 && node.Elements["/Ff"] is PdfInteger fi) fieldFlags = fi.Value; if (combLen == 0 && node.Elements["/MaxLen"] is PdfInteger ml) combLen = ml.Value; var pi = node.Elements["/Parent"]; if (pi is null) break; node = pi as PdfDictionary ?? DerefItem(pi) as PdfDictionary; } bool isMultiLine = (fieldFlags & 4096) != 0; bool isComb = (fieldFlags & (1 << 24)) != 0 && combLen > 0 && !isMultiLine; if (_formTextValues.TryGetValue(objNum, out var textVal) && fieldDict is not null) { fieldDict.Elements["/V"] = new PdfString(textVal); // Bake a per-field font-size override (from the size stepper) into the // field's /DA so the saved appearance and any later editor use it. if (_formFontSizes.TryGetValue(objNum, out var ovPt) && ovPt > 0) { daStr = WithDaFontSize(daStr, ovPt); fieldDict.Elements["/DA"] = new PdfString(daStr); } GenerateTextFieldAppearance(ann, textVal, daStr, fieldW, fieldH, isMultiLine, isComb ? combLen : 0); } else if (_formCheckValues.TryGetValue(objNum, out var checkVal) && fieldDict is not null) { string onVal = "/Yes"; try { var apDict = ann.Elements.GetDictionary("/AP"); var nDict = apDict?.Elements.GetDictionary("/N"); if (nDict is not null) foreach (var k in nDict.Elements.Keys) if (k != "/Off") { onVal = k; break; } } catch { } fieldDict.Elements["/V"] = new PdfName(checkVal ? onVal : "/Off"); fieldDict.Elements["/AS"] = new PdfName(checkVal ? onVal : "/Off"); ann.Elements["/AS"] = new PdfName(checkVal ? onVal : "/Off"); GenerateCheckBoxAppearance(ann, checkVal, onVal, fieldW, fieldH); } else if (_formRadioValues.Count > 0 && fieldDict is not null) { // Radio button: look up by field name (shared across all widgets in the group) string ft2 = fieldDict.Elements["/FT"]?.ToString() ?? ""; if (ft2.Contains("Btn")) { // Walk to find /T on the parent field node string fieldName2 = ""; var n2 = fieldDict; while (n2 is not null && string.IsNullOrEmpty(fieldName2)) { if (n2.Elements["/T"] is PdfString ts2) fieldName2 = ts2.Value; var pi2 = n2.Elements["/Parent"]; if (pi2 is null) break; n2 = pi2 as PdfDictionary ?? DerefItem(pi2) as PdfDictionary; } if (_formRadioValues.TryGetValue(fieldName2, out var radioSel)) { // Set /V on the parent field fieldDict.Elements["/V"] = new PdfName(radioSel); // Set /AS on this widget to show selected or off string onVal2 = "/Yes"; try { var apD = ann.Elements.GetDictionary("/AP"); var nD = apD?.Elements.GetDictionary("/N"); if (nD is not null) foreach (var k in nD.Elements.Keys) if (k != "/Off") { onVal2 = k; break; } } catch { } ann.Elements["/AS"] = new PdfName(onVal2 == radioSel ? onVal2 : "/Off"); } } } } } // Belt-and-suspenders: also set NeedAppearances in case any AP generation failed try { var acroForm = _doc.Internals.Catalog.Elements.GetDictionary("/AcroForm"); if (acroForm is not null) acroForm.Elements["/NeedAppearances"] = new PdfBoolean(true); } catch { } } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"WriteFormValuesToDocument: {ex}"); } } /// /// Generates a /AP /N form XObject appearance stream for a text field and sets it /// on the widget annotation. Uses reflection to access PdfSharpCore's internal /// PdfDictionary.PdfStream constructor since there is no public factory method. /// private void GenerateTextFieldAppearance(PdfDictionary widgetAnn, string text, string? da, double fieldW, double fieldH, bool isMultiLine, int combLen = 0) { try { const double pad = 2; // left/right inset, matching the Td origin below var (fontName, fontSize) = ParseDaString(da); if (fontSize <= 0) fontSize = Math.Max(6, Math.Min(fieldH * 0.65, 12)); // The "no taller than 85% of the box" clamp is a single-line rule: a multiline // field is as tall as it needs to be for several lines, so applying it there // blew the text up to the height of the whole box. fontSize = isMultiLine ? Math.Max(6, fontSize) : Math.Max(6, Math.Min(fontSize, fieldH * 0.85)); // #158: comb - one character per evenly-spaced cell, the way Acrobat fills the // printed boxes. Each glyph is positioned at its cell's center (Helvetica-class // average advance ~0.55em, so half a glyph is ~0.275em); the ordinary single-run // path below would bunch everything at the left edge of the first cell. if (combLen > 0) { double cellW = fieldW / combLen; fontSize = Math.Max(6, Math.Min(fontSize, Math.Min(fieldH * 0.85, cellW * 1.4))); string oneLine = text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' '); if (oneLine.Length > combLen) oneLine = oneLine[..combLen]; double combY = (fieldH - fontSize) / 2 + fontSize * 0.2; if (combY < 1) combY = 1; // Invariant, like every number below: interpolation formats with the OS culture, // and a comma decimal (de-DE and most European locales) is not a valid PDF number // token - the whole appearance stream then fails to execute in strict viewers. var csb = new System.Text.StringBuilder(); csb.Append(FormattableString.Invariant($"/Tx BMC\nq\n0 0 {fieldW:F2} {fieldH:F2} re W n\n")); csb.Append(FormattableString.Invariant($"BT\n{fontName} {fontSize:F2} Tf\n0 g\n")); for (int i = 0; i < oneLine.Length; i++) { if (oneLine[i] == ' ') continue; double gx = i * cellW + cellW / 2 - fontSize * 0.275; csb.Append(FormattableString.Invariant($"1 0 0 1 {gx:F2} {combY:F2} Tm\n({EscapePdfString(oneLine[i].ToString())}) Tj\n")); } csb.Append("ET\nQ\nEMC"); var combXobj = BuildFormXObject(fontName, fieldW, fieldH, csb.ToString()); if (combXobj is null) return; AttachAppearance(widgetAnn, combXobj); return; } // Tj shows a string; it has no concept of a line break, so a value with newlines // in it drew as one run with the breaks swallowed. Lay the value out into lines // and show each one, moving down by the leading between them. var lines = isMultiLine ? WrapFieldText(text, Math.Max(1, fieldW - pad * 2), fontSize) : [text.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ')]; double leading = fontSize * 1.16; // PDF baselines are measured from the bottom of the field rect. Multiline text // starts at the top and runs down; a single line stays vertically centered. double textY = isMultiLine ? fieldH - fontSize : (fieldH - fontSize) / 2 + fontSize * 0.2; if (textY < 1) textY = 1; // Invariant: a comma decimal from the OS culture is not a valid PDF number token. var sb = new System.Text.StringBuilder(); sb.Append(FormattableString.Invariant($"/Tx BMC\nq\n0 0 {fieldW:F2} {fieldH:F2} re W n\n")); sb.Append(FormattableString.Invariant($"BT\n{fontName} {fontSize:F2} Tf\n0 g\n{leading:F2} TL\n{pad:F2} {textY:F2} Td\n")); for (int i = 0; i < lines.Count; i++) { if (i > 0) sb.Append("T*\n"); // down one leading, back to the left inset sb.Append($"({EscapePdfString(lines[i])}) Tj\n"); } sb.Append("ET\nQ\nEMC"); // Lines past the bottom of the box are clipped by the "re W n" above, the same // way a viewer clips an over-full field. var xobj = BuildFormXObject(fontName, fieldW, fieldH, sb.ToString()); if (xobj is null) return; AttachAppearance(widgetAnn, xobj); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GenerateTextFieldAppearance: {ex}"); } } /// /// Splits a multiline field's value into the lines its appearance should draw: the value's /// own line breaks first, then greedy word-wrap to the field's inner width. /// // Measured with Arial, which is metric-compatible with the Helvetica the generated // appearance stream asks for, so the wrap lands where the drawn glyphs do. private static List WrapFieldText(string text, double innerWidth, double fontSize) { var typeface = new Typeface("Arial"); double Width(string s) => new FormattedText( s, System.Globalization.CultureInfo.InvariantCulture, FlowDirection.LeftToRight, typeface, fontSize, Brushes.Black, 1.0).Width; var lines = new List(); foreach (var para in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n')) { string current = string.Empty; foreach (var word in para.Split(' ')) { string candidate = current.Length == 0 ? word : current + " " + word; // A single word wider than the field can't be broken any further - let it // run on and be clipped rather than dropping it onto an empty line. if (current.Length > 0 && Width(candidate) > innerWidth) { lines.Add(current); current = word; } else current = candidate; } lines.Add(current); } return lines; } /// /// Generates /AP /N (checked) and /AP /Off (unchecked) appearance streams for a /// checkbox widget and sets them on the annotation. /// // isChecked unused - both AP states are always generated; /AS selects the active one private void GenerateCheckBoxAppearance(PdfDictionary widgetAnn, bool isChecked, string onVal, double fieldW, double fieldH) { try { double m = Math.Min(fieldW, fieldH) * 0.1; // margin double iw = fieldW - m * 2; double ih = fieldH - m * 2; // Checked: ZapfDingbats "4" = check, centered in the field double fs = Math.Min(iw, ih) * 0.85; double tx = (fieldW - fs * 0.6) / 2; double ty = (fieldH - fs) / 2 + fs * 0.15; // Invariant: a comma decimal from the OS culture is not a valid PDF number token. string checkedContent = FormattableString.Invariant( $"q\nBT\n/ZaDb {fs:F2} Tf\n0 g\n{tx:F2} {ty:F2} Td\n(4) Tj\nET\nQ"); string offContent = "q\nQ"; // empty - just clears // /Resources needs ZapfDingbats font for the checked state var checkedXobj = BuildFormXObject("/ZaDb", fieldW, fieldH, checkedContent, isZaDb: true); var offXobj = BuildFormXObject("/ZaDb", fieldW, fieldH, offContent, isZaDb: true); if (checkedXobj is null || offXobj is null) return; // /AP dictionary with /N being a sub-dict keyed by state name var nDict = new PdfDictionary(_doc); nDict.Elements[onVal] = checkedXobj.Reference; nDict.Elements["/Off"] = offXobj.Reference; var apDict = new PdfDictionary(_doc); apDict.Elements["/N"] = nDict; widgetAnn.Elements["/AP"] = apDict; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"GenerateCheckBoxAppearance: {ex}"); } } /// /// Creates an indirect PdfDictionary stream object representing a Form XObject, /// suitable for use as an /AP /N appearance stream. /// private PdfDictionary? BuildFormXObject(string fontName, double w, double h, string content, bool isZaDb = false) { byte[] bytes = System.Text.Encoding.GetEncoding("iso-8859-1").GetBytes(content); var xobj = new PdfDictionary(_doc); xobj.Elements["/Type"] = new PdfName("/XObject"); xobj.Elements["/Subtype"] = new PdfName("/Form"); xobj.Elements["/FormType"] = new PdfInteger(1); var bbox = new PdfArray(_doc); bbox.Elements.Add(new PdfReal(0)); bbox.Elements.Add(new PdfReal(0)); bbox.Elements.Add(new PdfReal(w)); bbox.Elements.Add(new PdfReal(h)); xobj.Elements["/BBox"] = bbox; // Inline font resource - avoids adding top-level objects for every field. var fontEntry = new PdfDictionary(_doc); fontEntry.Elements["/Type"] = new PdfName("/Font"); fontEntry.Elements["/Subtype"] = new PdfName("/Type1"); fontEntry.Elements["/BaseFont"] = isZaDb ? new PdfName("/ZapfDingbats") : new PdfName("/Helvetica"); if (!isZaDb) fontEntry.Elements["/Encoding"] = new PdfName("/WinAnsiEncoding"); var fontDict = new PdfDictionary(_doc); fontDict.Elements[fontName] = fontEntry; var res = new PdfDictionary(_doc); res.Elements["/Font"] = fontDict; xobj.Elements["/Resources"] = res; // CreateStream, not a hand-attached PdfStream: it is the only path that also writes // /Length, which every PDF stream must carry. Attaching the stream object directly // (the old reflection helper) left the appearance stream with no /Length, so the saved // file was structurally invalid - PdfSharpCore's own parser refuses it ("Cannot // retrieve stream length"), which is why MmdPdf met its own saved form with the // repair prompt, and strict viewers reported a damaged structure (#179). The Debug // assert that would have caught it (PdfDictionary.WriteObject) is compiled out of // Release builds. xobj.CreateStream(bytes); _doc!.Internals.AddObject(xobj); return xobj; } /// /// Sets /AP /N on a widget annotation to the given form XObject (indirect ref). /// Replaces any existing AP entry. /// private static void AttachAppearance(PdfDictionary widgetAnn, PdfDictionary xobj) { var apDict = new PdfDictionary(); apDict.Elements["/N"] = xobj.Reference; widgetAnn.Elements["/AP"] = apDict; } /// /// Parses a PDF Default Appearance string ("/Helv 12 Tf 0 g") to extract /// the font resource name and point size. /// private static (string fontName, double fontSize) ParseDaString(string? da) { string fontName = "/Helv"; double fontSize = 0; if (string.IsNullOrWhiteSpace(da)) return (fontName, fontSize); var tokens = da!.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i + 2 < tokens.Length; i++) { if (tokens[i + 2] == "Tf" && double.TryParse(tokens[i + 1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out double fs)) { fontName = tokens[i]; // e.g. "/Helv" fontSize = fs; break; } } return (fontName, fontSize); } // The appearance streams declare /WinAnsiEncoding, which is code page 1252. Unmappable // characters fall back to '?' on their own, which is what the old Latin-1 cut did to // everything - the point of going through the code page is that most of what got cut // is mappable. Held static: building an Encoding per character is not free. private static readonly System.Text.Encoding WinAnsi = System.Text.Encoding.GetEncoding(1252); /// /// Escapes a string for use in a PDF literal string (parentheses syntax). /// private static string EscapePdfString(string s) { var sb = new System.Text.StringBuilder(s.Length); foreach (char c in s) { switch (c) { case '\\': sb.Append("\\\\"); break; case '(': sb.Append("\\("); break; case ')': sb.Append("\\)"); break; case '\r': sb.Append("\\r"); break; case '\n': sb.Append("\\n"); break; default: // Anything above U+00FF used to become '?', which threw away the curly // quotes, dashes, bullets and ellipses every word processor emits - and // WinAnsi has slots for all of them at 0x80-0x9F. Map through the code // page the appearance actually uses; the byte is then written as-is by // BuildFormXObject's Latin-1 pass, and genuinely unmappable characters // (CJK and the like) still come out as '?'. if (c < 256) { sb.Append(c); break; } var mapped = WinAnsi.GetBytes(c.ToString()); sb.Append(mapped.Length > 0 ? (char)mapped[0] : '?'); break; } } return sb.ToString(); } // Form-field font-size stepper // A small "Font size: - N +" bar shown while a form text field is focused, so the user can // resize that field's text (PDF forms otherwise lock the size to the field's /DA). The chosen // size is stored per field and baked into the field's /DA on save. // // Dressed like the annotate bars (same surface, grain, shadow, fade) but ANCHORED TO THE // FIELD: the bar drips down from the box being typed in, dropdown-style, and follows it // through scrolling and zoom. It flips above the field when there is no room below, so it // can never collide with the annotate bars or float detached over the page. private ScrollChangedEventHandler? _formBarScrollHook; // detached in HideFormSizeBar private void ShowFormSizeBar(TextBox tb, int objNum, double scale) { HideFormSizeBar(); _activeFormTb = tb; _activeFormObj = objNum; _activeFormScale = scale > 0 ? scale : 1; double curPt = Math.Round(_activeFormTb.FontSize / _activeFormScale); var panel = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(3, 1, 3, 1), Background = Brushes.Transparent }; // Fixed light text: the InlineFlyout pill is dark regardless of the app theme. var lbl = new TextBlock { Text = Loc("Str_Forms_FontSize"), FontFamily = UiKit.UiFont, FontSize = 10, Foreground = new SolidColorBrush(Color.FromRgb(0xB8, 0xB8, 0xB8)), VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0) }; panel.Children.Add(lbl); var sizeLbl = new TextBlock { Text = curPt.ToString("0"), FontFamily = UiKit.UiFont, FontSize = 11, Foreground = new SolidColorBrush(Color.FromRgb(0xF2, 0xF2, 0xF2)), MinWidth = 20, TextAlignment = TextAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; panel.Children.Add(MakeFormSizeStep("", () => AdjustFormFontSize(-1, sizeLbl))); // minus panel.Children.Add(sizeLbl); panel.Children.Add(MakeFormSizeStep("", () => AdjustFormFontSize(+1, sizeLbl))); // plus // The on-document "inline flyout" style: translucent pill, solidifies on hover. _formSizeBar = UiKit.InlineFlyout(panel); _formSizeBar.HorizontalAlignment = HorizontalAlignment.Left; _formSizeBar.VerticalAlignment = VerticalAlignment.Top; if (PagePreviewPanel.Parent is Grid g) { var bar = _formSizeBar; // Just under the field, aligned to its left edge (clamped inside the pane); flips // above the field when it sits at the bottom. Re-run on scroll/zoom and on the // bar's own size changes so it rides with the box instead of hanging in space. void Reposition() { if (_formSizeBar != bar || _activeFormTb is null) return; try { double barW = bar.ActualWidth > 0 ? bar.ActualWidth : 160; double barH = bar.ActualHeight > 0 ? bar.ActualHeight : 34; var below = _activeFormTb.TranslatePoint(new Point(0, _activeFormTb.ActualHeight), g); double x = Math.Max(0, Math.Min(below.X, g.ActualWidth - barW)); double y = below.Y + 4; if (y + barH > g.ActualHeight) y = Math.Max(0, _activeFormTb.TranslatePoint(new Point(0, 0), g).Y - barH - 4); bar.Margin = new Thickness(x, y, 0, 0); } catch { /* field mid-layout; the next scroll/size tick repositions */ } } Panel.SetZIndex(bar, 100); g.Children.Add(bar); Reposition(); bar.SizeChanged += (_, _) => Reposition(); // the first real measure replaces the estimate _formBarScrollHook = (_, _) => Reposition(); PagePreviewPanel.ScrollChanged += _formBarScrollHook; // Fade in to the pill's translucent rest state (hover solidifies it from there). bar.Opacity = 0; bar.BeginAnimation(UIElement.OpacityProperty, new DoubleAnimation(0, UiKit.InlineFlyoutRestOpacity, new Duration(TimeSpan.FromMilliseconds(120))) { EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } }); } } // A flat, non-focusable +/- step. It's a Border (not a Button) so clicking it doesn't move // keyboard focus out of the text field, which would otherwise blur the field and dismiss this // bar. The minus/plus are DRAWN (centered rounded rectangles), not font glyphs: the icon font // and the number's text font carry different line metrics, so glyph-based signs sat on a // slightly different vertical axis than the size readout between them and read as misaligned. // Fixed light color: the InlineFlyout pill is dark regardless of the app theme. // Shim for the original glyph-string call sites: E710 is the MDL2 Add glyph, anything // else is the minus. The glyphs themselves are no longer rendered (see above). private Border MakeFormSizeStep(string glyph, Action onClick) => MakeFormSizeStep(glyph == "", onClick); private Border MakeFormSizeStep(bool plus, Action onClick) { var fill = new SolidColorBrush(Color.FromRgb(0xEE, 0xEE, 0xEE)); var shape = new Grid { Width = 9, Height = 9, SnapsToDevicePixels = true, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; shape.Children.Add(new Border { Width = 9, Height = 1.6, CornerRadius = new CornerRadius(0.8), Background = fill, VerticalAlignment = VerticalAlignment.Center }); if (plus) shape.Children.Add(new Border { Width = 1.6, Height = 9, CornerRadius = new CornerRadius(0.8), Background = fill, HorizontalAlignment = HorizontalAlignment.Center }); var b = new Border { Width = 21, Height = 19, CornerRadius = new CornerRadius(9), Cursor = Cursors.Hand, Margin = new Thickness(2, 0, 2, 0), Background = Brushes.Transparent, Child = shape }; b.MouseEnter += (_, _) => b.Background = new SolidColorBrush(Color.FromArgb(0x24, 0xFF, 0xFF, 0xFF)); b.MouseLeave += (_, _) => b.Background = Brushes.Transparent; b.MouseLeftButtonDown += (_, e) => { e.Handled = true; onClick(); }; return b; } private void AdjustFormFontSize(int delta, TextBlock sizeLbl) { if (_activeFormTb is null) return; double scale = _activeFormScale > 0 ? _activeFormScale : 1; double pt = Math.Round(_activeFormTb.FontSize / scale); pt = Math.Max(4, Math.Min(96, pt + delta)); _formFontSizes[_activeFormObj] = pt; _activeFormTb.FontSize = pt * scale; sizeLbl.Text = pt.ToString("0"); MarkDirty(true); } private void HideFormSizeBar() { if (_formBarScrollHook is not null) { PagePreviewPanel.ScrollChanged -= _formBarScrollHook; _formBarScrollHook = null; } if (_formSizeBar is not null) { FadeOutAndRemoveBar(_formSizeBar); // annotate-bar fade-out; removes it from its parent _formSizeBar = null; } } // Returns a /DA default-appearance string with its font size replaced (or a sensible default // when none exists), used to bake a user font-size override into the saved field. private static string WithDaFontSize(string? da, double pt) { string size = pt.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture); if (string.IsNullOrWhiteSpace(da)) return $"/Helv {size} Tf 0 g"; var t = da!.Split([' ', '\t', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries).ToList(); for (int i = 1; i < t.Count; i++) if (t[i] == "Tf") { t[i - 1] = size; return string.Join(" ", t); } return $"/Helv {size} Tf " + da; // no Tf operator present; prepend a font selection } // Guided AcroForm signing ------------------------------------------------------------------- private enum FormFillRole { None, Signature, Initials, Date } // Classifies a fillable field into a guided-signing role. A true PDF signature field // (/FT /Sig) is authoritative. Otherwise the name is matched on WHOLE WORDS, so labels // like "Computer Assigned" (contains the letters "sign") or "candidate"/"update" (contain // "date") are not mistaken for sign/date zones. Checkboxes, radios, dropdowns are never roles. private static FormFillRole ClassifyFormField(FormFieldInfo f) { if (f.IsCheckBox || f.IsRadio || f.FieldType == "/Ch") return FormFillRole.None; // A real signature field declares /FT /Sig - trust it regardless of name. if (f.FieldType.Contains("Sig")) return FormFillRole.Signature; string n = (f.FieldName ?? string.Empty).ToLowerInvariant(); bool Word(string pattern) => System.Text.RegularExpressions.Regex.IsMatch(n, pattern); if (Word(@"\binitials?\b")) return FormFillRole.Initials; if (Word(@"\b(signature|signed|sign)\b")) return FormFillRole.Signature; if (Word(@"\bdated?\b")) return FormFillRole.Date; return FormFillRole.None; } // A highlighted, clickable overlay sized to the field rectangle. Clicking fills it. private UIElement BuildSignZone(FormFieldInfo f, bool initials, int pageIndex) { var accent = Color.FromRgb(0x2a, 0x6e, 0xa5); var zone = new Border { Tag = FormOverlayTag, Width = f.Cw, Height = f.Ch, Background = new SolidColorBrush(Color.FromArgb(38, accent.R, accent.G, accent.B)), BorderBrush = new SolidColorBrush(Color.FromArgb(190, accent.R, accent.G, accent.B)), BorderThickness = new Thickness(1.4), CornerRadius = new CornerRadius(2), Cursor = Cursors.Hand, ToolTip = initials ? "Click to add your initials" : "Click to sign", Child = new TextBlock { Text = initials ? "Initial" : "Sign", FontSize = Math.Max(8, Math.Min(f.Ch * 0.45, 12)), FontWeight = FontWeights.SemiBold, Foreground = new SolidColorBrush(accent), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false, }, }; double zx = f.Cx, zy = f.Cy, zw = f.Cw, zh = f.Ch; int zp = pageIndex, zo = f.ObjNum; zone.MouseLeftButtonDown += (_, e) => { e.Handled = true; FillSignField(initials, zo, zp, zx, zy, zw, zh); }; return zone; } } }