windowcapture
исходный код / Integration/WordIntegration.cs

WordIntegration.cs

1411 строк · 65,161 байт · модуль Integration
   1using System;
   2using System.Collections.Generic;
   3using System.Drawing;
   4using System.Drawing.Imaging;
   5using System.IO;
   6using System.Runtime.InteropServices;
   7using System.Text.RegularExpressions;
   8using WindowCapture.Helpers;
   9
  10namespace WindowCapture.Integration
  11{
  12    /// <summary>
  13    /// Info about an open Word document
  14    /// </summary>
  15    public class WordDocumentInfo
  16    {
  17        public string Name;
  18        public string FullPath;
  19        public object DocumentObject; // Word.Document COM object
  20
  21        public override string ToString()
  22        {
  23            return Name;
  24        }
  25    }
  26
  27    /// <summary>
  28    /// Represents a text item (description or caption) found in a Word document
  29    /// </summary>
  30    public class DocumentTextItem
  31    {
  32        public int ParagraphIndex;       // 1-based index in doc.Paragraphs
  33        public string OriginalText;      // Full original text
  34        public string TextToRephrase;    // Text sent to Gemini (for captions: without prefix)
  35        public bool IsCaption;           // true if "Рисунок N — ..." format
  36        public string CaptionPrefix;     // e.g. "Рисунок 1 — " (preserved, not sent to Gemini)
  37        public string NewText;           // Rephrased text from Gemini
  38        public bool Accepted = true;     // User accepted in preview dialog
  39    }
  40
  41    /// <summary>
  42    /// Integration with Microsoft Word via COM automation
  43    /// </summary>
  44    public static class WordIntegration
  45    {
  46        private static object wordApp;
  47
  48        private static void Log(string message)
  49        {
  50            Logger.Log("word", message);
  51        }
  52        private static WordDocumentInfo selectedDocument;
  53
  54        /// <summary>
  55        /// Deterministically release a transient COM RCW. Late-bound automation spawns many
  56        /// short-lived wrappers (Selection/Range/Font/Fields/...) that otherwise linger until GC,
  57        /// keeping WINWORD.EXE alive (the GC.Collect() in Cleanup is the current band-aid).
  58        /// NEVER pass wordApp or a stored DocumentObject here — their lifetime is intentional.
  59        /// </summary>
  60        private static void SafeRelease(ref object o)
  61        {
  62            if (o == null) return;
  63            try { while (Marshal.ReleaseComObject(o) > 0) { } }
  64            catch { }
  65            o = null;
  66        }
  67
  68        // Word constants
  69        private const int wdCollapseEnd = 0;
  70        private const int wdAlignParagraphLeft = 0;
  71        private const int wdAlignParagraphCenter = 1;
  72        private const int wdAlignParagraphJustify = 3;
  73        private const int wdLineSpaceMultiple = 5;
  74        private const int wdStory = 6;
  75        private const int wdLine = 5;
  76        private const int wdExtend = 1;
  77        private const int wdCaptionPositionBelow = 1;
  78        private const int wdCaptionPositionAbove = 0;
  79
  80        /// <summary>
  81        /// Check if Word is available
  82        /// </summary>
  83        public static bool IsWordInstalled()
  84        {
  85            try
  86            {
  87                Type wordType = Type.GetTypeFromProgID("Word.Application");
  88                return wordType != null;
  89            }
  90            catch
  91            {
  92                return false;
  93            }
  94        }
  95
  96        /// <summary>
  97        /// Get currently running Word application, or null if none
  98        /// </summary>
  99        public static object GetRunningWordApp()
 100        {
 101            try
 102            {
 103                // Try to get running instance
 104                return Marshal.GetActiveObject("Word.Application");
 105            }
 106            catch
 107            {
 108                return null;
 109            }
 110        }
 111
 112        /// <summary>
 113        /// Get list of open Word documents
 114        /// </summary>
 115        public static List<WordDocumentInfo> GetOpenDocuments()
 116        {
 117            var docs = new List<WordDocumentInfo>();
 118
 119            try
 120            {
 121                object app = GetRunningWordApp();
 122                if (app == null) return docs;
 123
 124                wordApp = app;
 125
 126                // Get Documents collection
 127                object documents = app.GetType().InvokeMember("Documents",
 128                    System.Reflection.BindingFlags.GetProperty, null, app, null);
 129
 130                // Get count
 131                int count = (int)documents.GetType().InvokeMember("Count",
 132                    System.Reflection.BindingFlags.GetProperty, null, documents, null);
 133
 134                for (int i = 1; i <= count; i++)
 135                {
 136                    try
 137                    {
 138                        object doc = documents.GetType().InvokeMember("Item",
 139                            System.Reflection.BindingFlags.InvokeMethod, null, documents, new object[] { i });
 140
 141                        string name = (string)doc.GetType().InvokeMember("Name",
 142                            System.Reflection.BindingFlags.GetProperty, null, doc, null);
 143
 144                        string fullPath = "";
 145                        try
 146                        {
 147                            fullPath = (string)doc.GetType().InvokeMember("FullName",
 148                                System.Reflection.BindingFlags.GetProperty, null, doc, null);
 149                        }
 150                        catch { }
 151
 152                        docs.Add(new WordDocumentInfo
 153                        {
 154                            Name = name,
 155                            FullPath = fullPath,
 156                            DocumentObject = doc
 157                        });
 158                    }
 159                    catch { }
 160                }
 161
 162                // Release the Documents collection RCW (the individual doc RCWs are kept in 'docs').
 163                try { Marshal.ReleaseComObject(documents); } catch { }
 164            }
 165            catch { }
 166
 167            return docs;
 168        }
 169
 170        /// <summary>
 171        /// Get or set the selected Word document
 172        /// </summary>
 173        public static WordDocumentInfo SelectedDocument
 174        {
 175            get { return selectedDocument; }
 176            set { selectedDocument = value; }
 177        }
 178
 179        /// <summary>
 180        /// Insert image with description and caption into Word document
 181        /// Formatting:
 182        /// - Description: Times New Roman 14, 1.5 line spacing, 1.25cm indent, justified, black
 183        /// - Image: centered
 184        /// - Caption: Times New Roman 12, centered, black, format "Рисунок N — text"
 185        /// </summary>
 186        public static bool InsertImageWithCaption(Bitmap image, string description, string captionText)
 187        {
 188            Log("=== InsertImageWithCaption start ===");
 189            Log("Description: " + (description ?? "null"));
 190            Log("Caption: " + (captionText ?? "null"));
 191
 192            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 193            {
 194                Log("ERROR: selectedDocument is null");
 195                return false;
 196            }
 197
 198            try
 199            {
 200                object doc = selectedDocument.DocumentObject;
 201                object app = wordApp;
 202
 203                if (app == null)
 204                {
 205                    Log("wordApp is null, getting running Word app...");
 206                    app = GetRunningWordApp();
 207                    if (app == null)
 208                    {
 209                        Log("ERROR: Could not get Word app");
 210                        return false;
 211                    }
 212                    wordApp = app;
 213                }
 214
 215                Log("Getting next figure number...");
 216                // Get next figure number (count existing "Рисунок" captions + 1)
 217                int nextFigureNumber = GetNextFigureNumber(doc);
 218                Log("Next figure number: " + nextFigureNumber);
 219
 220                // Replace %PICNUM% in description with actual figure number
 221                if (!string.IsNullOrEmpty(description) && description.Contains("%PICNUM%"))
 222                {
 223                    description = description.Replace("%PICNUM%", nextFigureNumber.ToString());
 224                }
 225
 226                // Get Selection
 227                object selection = app.GetType().InvokeMember("Selection",
 228                    System.Reflection.BindingFlags.GetProperty, null, app, null);
 229
 230                // Move to end of document
 231                object range = doc.GetType().InvokeMember("Content",
 232                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 233                range.GetType().InvokeMember("Collapse",
 234                    System.Reflection.BindingFlags.InvokeMethod, null, range, new object[] { wdCollapseEnd });
 235
 236                // Select at end
 237                range.GetType().InvokeMember("Select",
 238                    System.Reflection.BindingFlags.InvokeMethod, null, range, null);
 239
 240                // Insert description if provided
 241                if (!string.IsNullOrEmpty(description))
 242                {
 243                    // Set font for description: Times New Roman 14, black
 244                    object font = selection.GetType().InvokeMember("Font",
 245                        System.Reflection.BindingFlags.GetProperty, null, selection, null);
 246                    font.GetType().InvokeMember("Name",
 247                        System.Reflection.BindingFlags.SetProperty, null, font, new object[] { "Times New Roman" });
 248                    font.GetType().InvokeMember("Size",
 249                        System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 14f });
 250                    font.GetType().InvokeMember("Color",
 251                        System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 0 }); // wdColorBlack = 0
 252
 253                    // Set paragraph format: 1.5 line spacing, 1.25cm indent, justified
 254                    object paragraphFormat = selection.GetType().InvokeMember("ParagraphFormat",
 255                        System.Reflection.BindingFlags.GetProperty, null, selection, null);
 256
 257                    // Line spacing: 1.5 (18 points for 12pt font base, but we use LineSpacing property)
 258                    paragraphFormat.GetType().InvokeMember("LineSpacingRule",
 259                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat, new object[] { wdLineSpaceMultiple });
 260                    paragraphFormat.GetType().InvokeMember("LineSpacing",
 261                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat, new object[] { 18f }); // 1.5 lines
 262
 263                    // First line indent: 1.25 cm = 35.4 points (1 cm = 28.35 points)
 264                    paragraphFormat.GetType().InvokeMember("FirstLineIndent",
 265                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat, new object[] { 35.4f });
 266
 267                    // Alignment: justified
 268                    paragraphFormat.GetType().InvokeMember("Alignment",
 269                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat, new object[] { wdAlignParagraphJustify });
 270
 271                    // Insert description text
 272                    selection.GetType().InvokeMember("TypeText",
 273                        System.Reflection.BindingFlags.InvokeMethod, null, selection, new object[] { description });
 274
 275                    // New paragraph after description
 276                    selection.GetType().InvokeMember("TypeParagraph",
 277                        System.Reflection.BindingFlags.InvokeMethod, null, selection, null);
 278                }
 279
 280                // Save image to temp file
 281                string tempImagePath = Path.Combine(Path.GetTempPath(), "wc_temp_" + DateTime.Now.Ticks + ".png");
 282                image.Save(tempImagePath, ImageFormat.Png);
 283
 284                try
 285                {
 286                    // Reset first line indent before image
 287                    object paragraphFormat2 = selection.GetType().InvokeMember("ParagraphFormat",
 288                        System.Reflection.BindingFlags.GetProperty, null, selection, null);
 289                    paragraphFormat2.GetType().InvokeMember("FirstLineIndent",
 290                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat2, new object[] { 0f });
 291
 292                    // Center alignment for image
 293                    paragraphFormat2.GetType().InvokeMember("Alignment",
 294                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat2, new object[] { wdAlignParagraphCenter });
 295
 296                    // Single line spacing for image paragraph (interdocument spacing = 0)
 297                    paragraphFormat2.GetType().InvokeMember("LineSpacingRule",
 298                        System.Reflection.BindingFlags.SetProperty, null, paragraphFormat2, new object[] { 0 }); // wdLineSpaceSingle
 299
 300                    // Insert image as InlineShape
 301                    object inlineShapes = selection.GetType().InvokeMember("InlineShapes",
 302                        System.Reflection.BindingFlags.GetProperty, null, selection, null);
 303
 304                    object inlineShape = inlineShapes.GetType().InvokeMember("AddPicture",
 305                        System.Reflection.BindingFlags.InvokeMethod, null, inlineShapes,
 306                        new object[] { tempImagePath, false, true }); // FileName, LinkToFile, SaveWithDocument
 307
 308                    // Scale image to fit 2 images per page
 309                    // A4 page: ~842pt height, margins ~144pt, usable ~700pt
 310                    // Per block (description + image + caption): ~350pt
 311                    // Description ~60pt, caption ~18pt, spacing ~30pt = ~108pt for text
 312                    // Max image height: ~240pt (~8.5cm) to fit 2 per page
 313                    // Max width: 425pt (15cm)
 314                    // Minimum scale: 75% (don't shrink more than 25%)
 315
 316                    float width = (float)inlineShape.GetType().InvokeMember("Width",
 317                        System.Reflection.BindingFlags.GetProperty, null, inlineShape, null);
 318                    float height = (float)inlineShape.GetType().InvokeMember("Height",
 319                        System.Reflection.BindingFlags.GetProperty, null, inlineShape, null);
 320
 321                    float maxWidth = 425f;  // 15cm
 322                    float maxHeight = 240f; // ~8.5cm to fit 2 per page
 323                    float minScale = 0.75f; // Don't shrink more than 25%
 324
 325                    float scaleW = width > maxWidth ? maxWidth / width : 1f;
 326                    float scaleH = height > maxHeight ? maxHeight / height : 1f;
 327                    float scale = Math.Min(scaleW, scaleH);
 328
 329                    // Apply minimum scale constraint
 330                    if (scale < minScale)
 331                        scale = minScale;
 332
 333                    if (scale < 1f)
 334                    {
 335                        float newWidth = width * scale;
 336                        float newHeight = height * scale;
 337                        inlineShape.GetType().InvokeMember("Width",
 338                            System.Reflection.BindingFlags.SetProperty, null, inlineShape, new object[] { newWidth });
 339                        inlineShape.GetType().InvokeMember("Height",
 340                            System.Reflection.BindingFlags.SetProperty, null, inlineShape, new object[] { newHeight });
 341                    }
 342
 343                    // Move to end of line after image
 344                    selection.GetType().InvokeMember("EndKey",
 345                        System.Reflection.BindingFlags.InvokeMethod, null, selection, new object[] { wdStory });
 346
 347                    // Insert caption if provided: "Рисунок N — text"
 348                    if (!string.IsNullOrEmpty(captionText))
 349                    {
 350                        // New paragraph for caption
 351                        selection.GetType().InvokeMember("TypeParagraph",
 352                            System.Reflection.BindingFlags.InvokeMethod, null, selection, null);
 353
 354                        // Ensure "Рисунок" caption label exists
 355                        EnsureFigureCaptionLabel(app);
 356
 357                        // Insert caption using InsertCaption method
 358                        // Parameters: Label, Title, TitleAutoText, Position, ExcludeLabel
 359                        // Title includes the dash and description text after the number
 360                        selection.GetType().InvokeMember("InsertCaption",
 361                            System.Reflection.BindingFlags.InvokeMethod, null, selection,
 362                            new object[] {
 363                                "Рисунок",                    // Label
 364                                " — " + captionText,          // Title (appears after number)
 365                                Type.Missing,                 // TitleAutoText
 366                                wdCaptionPositionBelow,       // Position
 367                                false                         // ExcludeLabel
 368                            });
 369
 370                        // Select the entire caption line to format it
 371                        // Move to start of line
 372                        selection.GetType().InvokeMember("HomeKey",
 373                            System.Reflection.BindingFlags.InvokeMethod, null, selection,
 374                            new object[] { wdLine });
 375                        // Extend selection to end of line
 376                        selection.GetType().InvokeMember("EndKey",
 377                            System.Reflection.BindingFlags.InvokeMethod, null, selection,
 378                            new object[] { wdLine, wdExtend });
 379
 380                        // Format the caption: Times New Roman 12, centered, black, not italic
 381                        object fontCaption = selection.GetType().InvokeMember("Font",
 382                            System.Reflection.BindingFlags.GetProperty, null, selection, null);
 383                        fontCaption.GetType().InvokeMember("Name",
 384                            System.Reflection.BindingFlags.SetProperty, null, fontCaption, new object[] { "Times New Roman" });
 385                        fontCaption.GetType().InvokeMember("Size",
 386                            System.Reflection.BindingFlags.SetProperty, null, fontCaption, new object[] { 12f });
 387                        fontCaption.GetType().InvokeMember("Color",
 388                            System.Reflection.BindingFlags.SetProperty, null, fontCaption, new object[] { 0 }); // wdColorBlack
 389                        fontCaption.GetType().InvokeMember("Italic",
 390                            System.Reflection.BindingFlags.SetProperty, null, fontCaption, new object[] { false }); // Remove italic
 391
 392                        object paragraphCaption = selection.GetType().InvokeMember("ParagraphFormat",
 393                            System.Reflection.BindingFlags.GetProperty, null, selection, null);
 394                        paragraphCaption.GetType().InvokeMember("Alignment",
 395                            System.Reflection.BindingFlags.SetProperty, null, paragraphCaption, new object[] { wdAlignParagraphCenter });
 396                        paragraphCaption.GetType().InvokeMember("FirstLineIndent",
 397                            System.Reflection.BindingFlags.SetProperty, null, paragraphCaption, new object[] { 0f });
 398                        // Normal line spacing for caption
 399                        paragraphCaption.GetType().InvokeMember("LineSpacingRule",
 400                            System.Reflection.BindingFlags.SetProperty, null, paragraphCaption, new object[] { 0 }); // wdLineSpaceSingle
 401
 402                        // Move to end of line (deselect) and add new paragraph
 403                        selection.GetType().InvokeMember("EndKey",
 404                            System.Reflection.BindingFlags.InvokeMethod, null, selection,
 405                            new object[] { wdLine });
 406                        selection.GetType().InvokeMember("TypeParagraph",
 407                            System.Reflection.BindingFlags.InvokeMethod, null, selection, null);
 408                    }
 409                    else
 410                    {
 411                        // No caption - still need new line after image
 412                        selection.GetType().InvokeMember("TypeParagraph",
 413                            System.Reflection.BindingFlags.InvokeMethod, null, selection, null);
 414                    }
 415                }
 416                finally
 417                {
 418                    // Clean up temp file
 419                    try { File.Delete(tempImagePath); } catch { }
 420                }
 421
 422                return true;
 423            }
 424            catch (Exception ex)
 425            {
 426                Log("ERROR: " + ex.Message + "\n" + ex.StackTrace);
 427                return false;
 428            }
 429        }
 430
 431        /// <summary>
 432        /// Get the next figure number by counting existing "Рисунок" captions in document
 433        /// </summary>
 434        private static int GetNextFigureNumber(object doc)
 435        {
 436            object fields = null;
 437            try
 438            {
 439                Log("GetNextFigureNumber: getting fields...");
 440                // Get all fields in the document
 441                fields = doc.GetType().InvokeMember("Fields",
 442                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 443
 444                int count = (int)fields.GetType().InvokeMember("Count",
 445                    System.Reflection.BindingFlags.GetProperty, null, fields, null);
 446                Log("GetNextFigureNumber: found " + count + " fields");
 447
 448                int figureCount = 0;
 449
 450                // Iterate through fields to count SEQ Рисунок fields
 451                for (int i = 1; i <= count; i++)
 452                {
 453                    object field = null, fieldCode = null;
 454                    try
 455                    {
 456                        field = fields.GetType().InvokeMember("Item",
 457                            System.Reflection.BindingFlags.InvokeMethod, null, fields, new object[] { i });
 458
 459                        fieldCode = field.GetType().InvokeMember("Code",
 460                            System.Reflection.BindingFlags.GetProperty, null, field, null);
 461
 462                        string codeText = (string)fieldCode.GetType().InvokeMember("Text",
 463                            System.Reflection.BindingFlags.GetProperty, null, fieldCode, null);
 464
 465                        // Caption fields use SEQ identifier, e.g. "SEQ Рисунок"
 466                        if (codeText != null && codeText.Contains("SEQ") && codeText.Contains("Рисунок"))
 467                        {
 468                            figureCount++;
 469                        }
 470                    }
 471                    catch (Exception ex) { Log("GetNextFigureNumber field error: " + ex.Message); }
 472                    finally { SafeRelease(ref fieldCode); SafeRelease(ref field); }
 473                }
 474
 475                return figureCount + 1;
 476            }
 477            catch
 478            {
 479                return 1; // Default to 1 if can't count
 480            }
 481            finally { SafeRelease(ref fields); }
 482        }
 483
 484        /// <summary>
 485        /// Ensure the "Рисунок" caption label exists in Word
 486        /// </summary>
 487        private static void EnsureFigureCaptionLabel(object app)
 488        {
 489            try
 490            {
 491                object captionLabels = app.GetType().InvokeMember("CaptionLabels",
 492                    System.Reflection.BindingFlags.GetProperty, null, app, null);
 493
 494                // Try to get existing "Рисунок" label
 495                try
 496                {
 497                    captionLabels.GetType().InvokeMember("Item",
 498                        System.Reflection.BindingFlags.InvokeMethod, null, captionLabels,
 499                        new object[] { "Рисунок" });
 500                    // Label exists, do nothing
 501                }
 502                catch
 503                {
 504                    // Label doesn't exist, add it
 505                    captionLabels.GetType().InvokeMember("Add",
 506                        System.Reflection.BindingFlags.InvokeMethod, null, captionLabels,
 507                        new object[] { "Рисунок" });
 508                }
 509            }
 510            catch { }
 511        }
 512
 513        /// <summary>
 514        /// Get the last description text from Word document (text before last image)
 515        /// Returns null if document is empty or has no descriptions
 516        /// </summary>
 517        public static string GetLastDescription()
 518        {
 519            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 520                return null;
 521
 522            try
 523            {
 524                object doc = selectedDocument.DocumentObject;
 525
 526                // Get document content
 527                object content = doc.GetType().InvokeMember("Content",
 528                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 529
 530                if (content == null)
 531                    return null;
 532
 533                string fullText = null;
 534                try
 535                {
 536                    fullText = (string)content.GetType().InvokeMember("Text",
 537                        System.Reflection.BindingFlags.GetProperty, null, content, null);
 538                }
 539                catch
 540                {
 541                    return null; // Document might be empty or inaccessible
 542                }
 543
 544                // If document is empty or only whitespace, return null (this is normal for new documents)
 545                if (string.IsNullOrEmpty(fullText) || fullText.Trim().Length < 20)
 546                    return null;
 547
 548                // Get paragraphs
 549                object paragraphs = null;
 550                try
 551                {
 552                    paragraphs = doc.GetType().InvokeMember("Paragraphs",
 553                        System.Reflection.BindingFlags.GetProperty, null, doc, null);
 554                }
 555                catch
 556                {
 557                    return null;
 558                }
 559
 560                if (paragraphs == null)
 561                    return null;
 562
 563                int count = 0;
 564                try
 565                {
 566                    count = (int)paragraphs.GetType().InvokeMember("Count",
 567                        System.Reflection.BindingFlags.GetProperty, null, paragraphs, null);
 568                }
 569                catch
 570                {
 571                    return null;
 572                }
 573
 574                if (count == 0)
 575                    return null;
 576
 577                // Find last meaningful paragraph (not caption, not empty)
 578                for (int i = count; i >= 1; i--)
 579                {
 580                    object para = null, range = null;
 581                    try
 582                    {
 583                        para = paragraphs.GetType().InvokeMember("Item",
 584                            System.Reflection.BindingFlags.InvokeMethod, null, paragraphs, new object[] { i });
 585
 586                        if (para == null) continue;
 587
 588                        range = para.GetType().InvokeMember("Range",
 589                            System.Reflection.BindingFlags.GetProperty, null, para, null);
 590
 591                        if (range == null) continue;
 592
 593                        string text = (string)range.GetType().InvokeMember("Text",
 594                            System.Reflection.BindingFlags.GetProperty, null, range, null);
 595
 596                        if (string.IsNullOrWhiteSpace(text))
 597                            continue;
 598
 599                        // Skip if it's a caption (starts with "Рисунок")
 600                        text = text.Trim();
 601                        if (text.StartsWith("Рисунок"))
 602                            continue;
 603
 604                        // Skip very short texts (likely formatting artifacts)
 605                        if (text.Length < 20)
 606                            continue;
 607
 608                        Log("GetLastDescription: found: " + text.Substring(0, Math.Min(50, text.Length)) + "...");
 609                        return text;
 610                    }
 611                    catch { }
 612                    finally { SafeRelease(ref range); SafeRelease(ref para); }
 613                }
 614
 615                // No description found - this is normal for new documents
 616                return null;
 617            }
 618            catch (Exception ex)
 619            {
 620                Log("GetLastDescription error: " + ex.Message);
 621                return null;
 622            }
 623        }
 624
 625        /// <summary>
 626        /// Bring Word window to foreground
 627        /// </summary>
 628        public static void ActivateWord()
 629        {
 630            try
 631            {
 632                if (wordApp != null)
 633                {
 634                    wordApp.GetType().InvokeMember("Activate",
 635                        System.Reflection.BindingFlags.InvokeMethod, null, wordApp, null);
 636                }
 637            }
 638            catch { }
 639        }
 640
 641        /// <summary>
 642        /// Get document page count for preview
 643        /// </summary>
 644        public static int GetPageCount()
 645        {
 646            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 647                return 0;
 648
 649            try
 650            {
 651                object doc = selectedDocument.DocumentObject;
 652                object builtInProps = doc.GetType().InvokeMember("BuiltInDocumentProperties",
 653                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 654                object pagesProp = builtInProps.GetType().InvokeMember("Item",
 655                    System.Reflection.BindingFlags.GetProperty, null, builtInProps, new object[] { 14 }); // wdPropertyPages
 656                return (int)pagesProp.GetType().InvokeMember("Value",
 657                    System.Reflection.BindingFlags.GetProperty, null, pagesProp, null);
 658            }
 659            catch
 660            {
 661                return 0;
 662            }
 663        }
 664
 665        /// <summary>
 666        /// Get document preview by copying last page content as picture
 667        /// Uses Word's CopyAsPicture to get document content
 668        /// </summary>
 669        public static Bitmap GetDocumentPreview()
 670        {
 671            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 672                return null;
 673
 674            // Ensure we have Word app reference
 675            if (wordApp == null)
 676            {
 677                wordApp = GetRunningWordApp();
 678                if (wordApp == null) return null;
 679            }
 680
 681            try
 682            {
 683                object doc = selectedDocument.DocumentObject;
 684
 685                // Get document content range
 686                object content = doc.GetType().InvokeMember("Content",
 687                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 688
 689                // Copy content as picture to clipboard
 690                content.GetType().InvokeMember("CopyAsPicture",
 691                    System.Reflection.BindingFlags.InvokeMethod, null, content, null);
 692
 693                // Get image from clipboard
 694                if (System.Windows.Forms.Clipboard.ContainsImage())
 695                {
 696                    Image img = System.Windows.Forms.Clipboard.GetImage();
 697                    if (img != null)
 698                    {
 699                        var bmp = BitmapHelper.Clone32(img);
 700                        img.Dispose();
 701                        return bmp;
 702                    }
 703                }
 704
 705                return null;
 706            }
 707            catch
 708            {
 709                return null;
 710            }
 711        }
 712
 713        /// <summary>
 714        /// Get screenshot of Word window for preview using PrintWindow API
 715        /// Works even if window is behind other windows
 716        /// </summary>
 717        public static Bitmap GetWordWindowScreenshot()
 718        {
 719            // Ensure we have Word app reference
 720            if (wordApp == null)
 721            {
 722                wordApp = GetRunningWordApp();
 723                if (wordApp == null) return null;
 724            }
 725
 726            try
 727            {
 728                // Get Word window handle
 729                int hwnd = (int)wordApp.GetType().InvokeMember("Hwnd",
 730                    System.Reflection.BindingFlags.GetProperty, null, wordApp, null);
 731
 732                if (hwnd == 0) return null;
 733
 734                IntPtr hWnd = new IntPtr(hwnd);
 735
 736                // Check if window is minimized
 737                if (IsIconic(hWnd))
 738                {
 739                    return null;
 740                }
 741
 742                // Get window rect
 743                WindowCapture.Native.WinApi.RECT rect;
 744                if (!GetWindowRect(hWnd, out rect)) return null;
 745
 746                int width = rect.Right - rect.Left;
 747                int height = rect.Bottom - rect.Top;
 748
 749                if (width <= 0 || height <= 0) return null;
 750
 751                // Use PrintWindow API to capture even if window is behind others
 752                Bitmap bmp = new Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
 753                using (Graphics g = Graphics.FromImage(bmp))
 754                {
 755                    IntPtr hdc = g.GetHdc();
 756                    bool success = PrintWindow(hWnd, hdc, PW_RENDERFULLCONTENT);
 757                    g.ReleaseHdc(hdc);
 758
 759                    if (!success)
 760                    {
 761                        g.CopyFromScreen(rect.Left, rect.Top, 0, 0, new Size(width, height));
 762                    }
 763                }
 764
 765                return bmp;
 766            }
 767            catch
 768            {
 769                return null;
 770            }
 771        }
 772
 773        private const uint PW_CLIENTONLY = 0x1;
 774        private const uint PW_RENDERFULLCONTENT = 0x2;
 775
 776        [DllImport("user32.dll")]
 777        private static extern bool GetWindowRect(IntPtr hWnd, out WindowCapture.Native.WinApi.RECT lpRect);
 778
 779        [DllImport("user32.dll")]
 780        private static extern bool PrintWindow(IntPtr hWnd, IntPtr hdcBlt, uint nFlags);
 781
 782        [DllImport("user32.dll")]
 783        private static extern bool IsIconic(IntPtr hWnd);
 784
 785        // Word constants for PasteAndFormat
 786        private const int wdFormatOriginalFormatting = 16; // Preserves original formatting
 787
 788        /// <summary>
 789        /// Insert title page from template at the beginning of document
 790        /// Replaces placeholders: %WORKNUMBER%, %DISCIPLINE%, %TOPIC%, %STUDENT%, %GROUP%, %TEACHER%
 791        /// Uses Copy + PasteAndFormat(wdFormatOriginalFormatting) to preserve exact source formatting
 792        /// </summary>
 793        public static bool InsertTitlePage(string templatePath, Dictionary<string, string> replacements)
 794        {
 795            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 796                return false;
 797
 798            if (string.IsNullOrEmpty(templatePath) || !File.Exists(templatePath))
 799                return false;
 800
 801            try
 802            {
 803                object doc = selectedDocument.DocumentObject;
 804                object app = wordApp;
 805
 806                if (app == null)
 807                {
 808                    app = GetRunningWordApp();
 809                    if (app == null) return false;
 810                    wordApp = app;
 811                }
 812
 813                // Open template document (ReadOnly)
 814                object documents = app.GetType().InvokeMember("Documents",
 815                    System.Reflection.BindingFlags.GetProperty, null, app, null);
 816
 817                object templateDoc = documents.GetType().InvokeMember("Open",
 818                    System.Reflection.BindingFlags.InvokeMethod, null, documents,
 819                    new object[] { templatePath, false, true }); // FileName, ConfirmConversions, ReadOnly
 820
 821                try
 822                {
 823                    // Copy entire content of template
 824                    object templateContent = templateDoc.GetType().InvokeMember("Content",
 825                        System.Reflection.BindingFlags.GetProperty, null, templateDoc, null);
 826                    templateContent.GetType().InvokeMember("Copy",
 827                        System.Reflection.BindingFlags.InvokeMethod, null, templateContent, null);
 828
 829                    // Activate target document
 830                    doc.GetType().InvokeMember("Activate",
 831                        System.Reflection.BindingFlags.InvokeMethod, null, doc, null);
 832
 833                    // Get selection and move to start of document
 834                    object selection = app.GetType().InvokeMember("Selection",
 835                        System.Reflection.BindingFlags.GetProperty, null, app, null);
 836                    selection.GetType().InvokeMember("HomeKey",
 837                        System.Reflection.BindingFlags.InvokeMethod, null, selection,
 838                        new object[] { wdStory }); // Move to beginning
 839
 840                    // PasteAndFormat with wdFormatOriginalFormatting (16) - keeps source formatting exactly
 841                    selection.GetType().InvokeMember("PasteAndFormat",
 842                        System.Reflection.BindingFlags.InvokeMethod, null, selection,
 843                        new object[] { wdFormatOriginalFormatting });
 844                }
 845                finally
 846                {
 847                    // Close template without saving
 848                    templateDoc.GetType().InvokeMember("Close",
 849                        System.Reflection.BindingFlags.InvokeMethod, null, templateDoc,
 850                        new object[] { 0 }); // wdDoNotSaveChanges = 0
 851                    Marshal.ReleaseComObject(templateDoc);
 852                }
 853
 854                // Perform replacements in the document
 855                if (replacements != null)
 856                {
 857                    object docContent = doc.GetType().InvokeMember("Content",
 858                        System.Reflection.BindingFlags.GetProperty, null, doc, null);
 859
 860                    object find = docContent.GetType().InvokeMember("Find",
 861                        System.Reflection.BindingFlags.GetProperty, null, docContent, null);
 862
 863                    foreach (var replacement in replacements)
 864                    {
 865                        // Clear previous find
 866                        find.GetType().InvokeMember("ClearFormatting",
 867                            System.Reflection.BindingFlags.InvokeMethod, null, find, null);
 868
 869                        object replace = find.GetType().InvokeMember("Replacement",
 870                            System.Reflection.BindingFlags.GetProperty, null, find, null);
 871                        replace.GetType().InvokeMember("ClearFormatting",
 872                            System.Reflection.BindingFlags.InvokeMethod, null, replace, null);
 873
 874                        // Set find text
 875                        find.GetType().InvokeMember("Text",
 876                            System.Reflection.BindingFlags.SetProperty, null, find, new object[] { replacement.Key });
 877
 878                        // Set replacement text
 879                        replace.GetType().InvokeMember("Text",
 880                            System.Reflection.BindingFlags.SetProperty, null, replace, new object[] { replacement.Value });
 881
 882                        // Execute replacement (wdReplaceAll = 2)
 883                        find.GetType().InvokeMember("Execute",
 884                            System.Reflection.BindingFlags.InvokeMethod, null, find,
 885                            new object[] {
 886                                Type.Missing,   // FindText (already set)
 887                                false,          // MatchCase
 888                                false,          // MatchWholeWord
 889                                false,          // MatchWildcards
 890                                false,          // MatchSoundsLike
 891                                false,          // MatchAllWordForms
 892                                true,           // Forward
 893                                1,              // Wrap (wdFindContinue)
 894                                false,          // Format
 895                                Type.Missing,   // ReplaceWith (already set)
 896                                2               // Replace (wdReplaceAll)
 897                            });
 898
 899                        // Reset range for next search
 900                        docContent = doc.GetType().InvokeMember("Content",
 901                            System.Reflection.BindingFlags.GetProperty, null, doc, null);
 902                        find = docContent.GetType().InvokeMember("Find",
 903                            System.Reflection.BindingFlags.GetProperty, null, docContent, null);
 904                    }
 905                }
 906
 907                return true;
 908            }
 909            catch (Exception ex)
 910            {
 911                System.Diagnostics.Debug.WriteLine("Title page insert error: " + ex.Message);
 912                return false;
 913            }
 914        }
 915
 916        /// <summary>
 917        /// Add page numbering with special first page (no number on first page, starts from 2 on second page)
 918        /// Footer: centered, TNR 14, black
 919        /// </summary>
 920        public static bool AddPageNumbering()
 921        {
 922            if (selectedDocument == null || selectedDocument.DocumentObject == null)
 923                return false;
 924
 925            try
 926            {
 927                object doc = selectedDocument.DocumentObject;
 928                object app = wordApp;
 929
 930                if (app == null)
 931                {
 932                    app = GetRunningWordApp();
 933                    if (app == null) return false;
 934                    wordApp = app;
 935                }
 936
 937                // Get active window
 938                object activeWindow = doc.GetType().InvokeMember("ActiveWindow",
 939                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 940
 941                // Get active pane
 942                object activePane = activeWindow.GetType().InvokeMember("ActivePane",
 943                    System.Reflection.BindingFlags.GetProperty, null, activeWindow, null);
 944
 945                // Get view
 946                object view = activePane.GetType().InvokeMember("View",
 947                    System.Reflection.BindingFlags.GetProperty, null, activePane, null);
 948
 949                // Set view to print layout (wdPrintView = 3)
 950                view.GetType().InvokeMember("Type",
 951                    System.Reflection.BindingFlags.SetProperty, null, view, new object[] { 3 });
 952
 953                // Get sections
 954                object sections = doc.GetType().InvokeMember("Sections",
 955                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
 956
 957                // Get first section
 958                object section = sections.GetType().InvokeMember("Item",
 959                    System.Reflection.BindingFlags.InvokeMethod, null, sections, new object[] { 1 });
 960
 961                // Get headers/footers
 962                object footers = section.GetType().InvokeMember("Footers",
 963                    System.Reflection.BindingFlags.GetProperty, null, section, null);
 964
 965                // Get primary footer (wdHeaderFooterPrimary = 1)
 966                object primaryFooter = footers.GetType().InvokeMember("Item",
 967                    System.Reflection.BindingFlags.InvokeMethod, null, footers, new object[] { 1 });
 968
 969                // Get page setup
 970                object pageSetup = section.GetType().InvokeMember("PageSetup",
 971                    System.Reflection.BindingFlags.GetProperty, null, section, null);
 972
 973                // Set different first page
 974                pageSetup.GetType().InvokeMember("DifferentFirstPageHeaderFooter",
 975                    System.Reflection.BindingFlags.SetProperty, null, pageSetup, new object[] { true });
 976
 977                // Get footer range
 978                object footerRange = primaryFooter.GetType().InvokeMember("Range",
 979                    System.Reflection.BindingFlags.GetProperty, null, primaryFooter, null);
 980
 981                // Clear footer first
 982                footerRange.GetType().InvokeMember("Delete",
 983                    System.Reflection.BindingFlags.InvokeMethod, null, footerRange, null);
 984
 985                // Get fresh range after delete
 986                footerRange = primaryFooter.GetType().InvokeMember("Range",
 987                    System.Reflection.BindingFlags.GetProperty, null, primaryFooter, null);
 988
 989                // Get page numbers collection
 990                object pageNumbers = primaryFooter.GetType().InvokeMember("PageNumbers",
 991                    System.Reflection.BindingFlags.GetProperty, null, primaryFooter, null);
 992
 993                // Add page number at center bottom (wdAlignPageNumberCenter = 1)
 994                // FirstPage = false means no page number on first page (due to DifferentFirstPageHeaderFooter)
 995                pageNumbers.GetType().InvokeMember("Add",
 996                    System.Reflection.BindingFlags.InvokeMethod, null, pageNumbers,
 997                    new object[] { 1, false }); // PageNumberAlignment = Center, FirstPage = false
 998
 999                // StartingNumber = 1 means first page is "1" (hidden), second page shows "2"
1000                pageNumbers.GetType().InvokeMember("StartingNumber",
1001                    System.Reflection.BindingFlags.SetProperty, null, pageNumbers, new object[] { 1 });
1002
1003                // Format the page number: TNR 14, black, centered
1004                footerRange = primaryFooter.GetType().InvokeMember("Range",
1005                    System.Reflection.BindingFlags.GetProperty, null, primaryFooter, null);
1006
1007                object font = footerRange.GetType().InvokeMember("Font",
1008                    System.Reflection.BindingFlags.GetProperty, null, footerRange, null);
1009
1010                font.GetType().InvokeMember("Name",
1011                    System.Reflection.BindingFlags.SetProperty, null, font, new object[] { "Times New Roman" });
1012                font.GetType().InvokeMember("Size",
1013                    System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 14f });
1014                font.GetType().InvokeMember("Color",
1015                    System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 0 }); // wdColorBlack
1016
1017                object paragraphFormat = footerRange.GetType().InvokeMember("ParagraphFormat",
1018                    System.Reflection.BindingFlags.GetProperty, null, footerRange, null);
1019                paragraphFormat.GetType().InvokeMember("Alignment",
1020                    System.Reflection.BindingFlags.SetProperty, null, paragraphFormat, new object[] { wdAlignParagraphCenter });
1021
1022                return true;
1023            }
1024            catch (Exception ex)
1025            {
1026                System.Diagnostics.Debug.WriteLine("Page numbering error: " + ex.Message);
1027                return false;
1028            }
1029        }
1030
1031        /// <summary>
1032        /// Check if Word has any open documents
1033        /// </summary>
1034        public static bool HasOpenDocuments()
1035        {
1036            try
1037            {
1038                object app = GetRunningWordApp();
1039                if (app == null) return false;
1040
1041                object documents = app.GetType().InvokeMember("Documents",
1042                    System.Reflection.BindingFlags.GetProperty, null, app, null);
1043
1044                int count = (int)documents.GetType().InvokeMember("Count",
1045                    System.Reflection.BindingFlags.GetProperty, null, documents, null);
1046
1047                return count > 0;
1048            }
1049            catch
1050            {
1051                return false;
1052            }
1053        }
1054
1055        // Word Information enum constants
1056        private const int wdActiveEndPageNumber = 3;
1057
1058        /// <summary>
1059        /// Extract all descriptions and captions from a Word document
1060        /// Skips page 1 (title page), returns list of DocumentTextItem
1061        /// </summary>
1062        public static List<DocumentTextItem> ExtractDescriptionsAndCaptions(object doc)
1063        {
1064            var items = new List<DocumentTextItem>();
1065            Log("=== ExtractDescriptionsAndCaptions start ===");
1066
1067            try
1068            {
1069                object paragraphs = doc.GetType().InvokeMember("Paragraphs",
1070                    System.Reflection.BindingFlags.GetProperty, null, doc, null);
1071
1072                int count = (int)paragraphs.GetType().InvokeMember("Count",
1073                    System.Reflection.BindingFlags.GetProperty, null, paragraphs, null);
1074
1075                Log("Total paragraphs: " + count);
1076
1077                // Caption regex — also match when text starts with hidden field chars before "Рисунок"
1078                var captionRegex = new Regex(@"Рисунок\s+\d+\s*[\-—–]\s*");
1079
1080                for (int i = 1; i <= count; i++)
1081                {
1082                    try
1083                    {
1084                        object para = paragraphs.GetType().InvokeMember("Item",
1085                            System.Reflection.BindingFlags.InvokeMethod, null, paragraphs, new object[] { i });
1086
1087                        object range = para.GetType().InvokeMember("Range",
1088                            System.Reflection.BindingFlags.GetProperty, null, para, null);
1089
1090                        // Check page number — skip page 1 (title page)
1091                        try
1092                        {
1093                            object pageNum = range.GetType().InvokeMember("Information",
1094                                System.Reflection.BindingFlags.GetProperty, null, range,
1095                                new object[] { wdActiveEndPageNumber });
1096                            int page = Convert.ToInt32(pageNum);
1097                            if (page <= 1)
1098                            {
1099                                continue;
1100                            }
1101                        }
1102                        catch (Exception pgEx)
1103                        {
1104                            Log("Page number check failed for para " + i + ": " + pgEx.Message + " — processing anyway");
1105                        }
1106
1107                        string text = (string)range.GetType().InvokeMember("Text",
1108                            System.Reflection.BindingFlags.GetProperty, null, range, null);
1109
1110                        if (string.IsNullOrWhiteSpace(text))
1111                            continue;
1112
1113                        text = text.Trim('\r', '\n', '\a');
1114
1115                        if (text.Length < 5)
1116                            continue;
1117
1118                        // Check if it's a caption: "Рисунок N — text"
1119                        // Use Search instead of Match to handle hidden field characters before "Рисунок"
1120                        var captionMatch = captionRegex.Match(text);
1121                        if (captionMatch.Success)
1122                        {
1123                            string prefix = text.Substring(0, captionMatch.Index + captionMatch.Length);
1124                            string captionText = text.Substring(captionMatch.Index + captionMatch.Length).Trim();
1125
1126                            if (captionText.Length > 2)
1127                            {
1128                                var item = new DocumentTextItem
1129                                {
1130                                    ParagraphIndex = i,
1131                                    OriginalText = text,
1132                                    TextToRephrase = captionText,
1133                                    IsCaption = true,
1134                                    CaptionPrefix = prefix
1135                                };
1136                                items.Add(item);
1137                                Log("Found caption [" + i + "]: " + text.Substring(0, Math.Min(80, text.Length)));
1138                            }
1139                            continue;
1140                        }
1141
1142                        // Check if it's a description: long text (>20 chars), not a caption
1143                        if (text.Length < 20)
1144                            continue;
1145
1146                        // Skip texts containing "Рисунок" (broken captions)
1147                        if (text.Contains("Рисунок"))
1148                            continue;
1149
1150                        // Check font and alignment to confirm it's a description paragraph
1151                        string fName = "";
1152                        float fSize = 0;
1153                        int align = -1;
1154                        try
1155                        {
1156                            object font = range.GetType().InvokeMember("Font",
1157                                System.Reflection.BindingFlags.GetProperty, null, range, null);
1158
1159                            object fontNameObj = font.GetType().InvokeMember("Name",
1160                                System.Reflection.BindingFlags.GetProperty, null, font, null);
1161
1162                            object fontSizeObj = font.GetType().InvokeMember("Size",
1163                                System.Reflection.BindingFlags.GetProperty, null, font, null);
1164
1165                            object paraFormat = para.GetType().InvokeMember("Format",
1166                                System.Reflection.BindingFlags.GetProperty, null, para, null);
1167
1168                            object alignObj = paraFormat.GetType().InvokeMember("Alignment",
1169                                System.Reflection.BindingFlags.GetProperty, null, paraFormat, null);
1170
1171                            fName = fontNameObj != null ? fontNameObj.ToString() : "";
1172                            fSize = Convert.ToSingle(fontSizeObj);
1173                            align = Convert.ToInt32(alignObj);
1174                        }
1175                        catch (Exception fmtEx)
1176                        {
1177                            Log("Format check failed for para " + i + ": " + fmtEx.Message);
1178                        }
1179
1180                        Log("Para [" + i + "] font='" + fName + "' size=" + fSize + " align=" + align + " len=" + text.Length + " text='" + text.Substring(0, Math.Min(50, text.Length)) + "'");
1181
1182                        // Accept description if:
1183                        // - It's long enough (>20 chars) AND
1184                        // - Either has typical formatting (TNR 12-14pt, justified/left) OR formatting unknown
1185                        bool isDescription = false;
1186
1187                        if (text.Length >= 20)
1188                        {
1189                            // Strict match: TNR, ~14pt, justified
1190                            if (fName.Contains("Times") && fSize >= 12f && fSize <= 15f &&
1191                                (align == wdAlignParagraphJustify || align == wdAlignParagraphLeft))
1192                            {
1193                                isDescription = true;
1194                            }
1195                            // Fallback: if font info is weird (mixed formatting returns 9999999),
1196                            // accept long paragraphs that aren't centered (centered = headings/titles)
1197                            else if (fSize > 100f && align != wdAlignParagraphCenter && text.Length >= 40)
1198                            {
1199                                isDescription = true;
1200                                Log("Para [" + i + "] accepted via fallback (mixed formatting)");
1201                            }
1202                            // Also accept if format check failed entirely but text is substantial
1203                            else if (fName == "" && text.Length >= 40)
1204                            {
1205                                isDescription = true;
1206                                Log("Para [" + i + "] accepted via fallback (no format info)");
1207                            }
1208                        }
1209
1210                        if (isDescription)
1211                        {
1212                            var item = new DocumentTextItem
1213                            {
1214                                ParagraphIndex = i,
1215                                OriginalText = text,
1216                                TextToRephrase = text,
1217                                IsCaption = false,
1218                                CaptionPrefix = null
1219                            };
1220                            items.Add(item);
1221                            Log("Found description [" + i + "]: " + text.Substring(0, Math.Min(60, text.Length)));
1222                        }
1223                    }
1224                    catch (Exception ex)
1225                    {
1226                        Log("Error processing paragraph " + i + ": " + ex.Message);
1227                    }
1228                }
1229
1230                Log("Total items found: " + items.Count);
1231            }
1232            catch (Exception ex)
1233            {
1234                Log("ExtractDescriptionsAndCaptions error: " + ex.Message);
1235            }
1236
1237            return items;
1238        }
1239
1240        /// <summary>
1241        /// Apply rephrased text replacements to the document
1242        /// Processes in reverse order to preserve paragraph indices
1243        /// </summary>
1244        public static int ApplyReplacements(object doc, List<DocumentTextItem> items)
1245        {
1246            Log("=== ApplyReplacements start ===");
1247            int replacedCount = 0;
1248
1249            // Sort by paragraph index descending (reverse order)
1250            var sorted = new List<DocumentTextItem>(items);
1251            sorted.Sort((a, b) => b.ParagraphIndex.CompareTo(a.ParagraphIndex));
1252
1253            object paragraphs = doc.GetType().InvokeMember("Paragraphs",
1254                System.Reflection.BindingFlags.GetProperty, null, doc, null);
1255
1256            foreach (var item in sorted)
1257            {
1258                if (!item.Accepted || string.IsNullOrEmpty(item.NewText))
1259                    continue;
1260
1261                try
1262                {
1263                    object para = paragraphs.GetType().InvokeMember("Item",
1264                        System.Reflection.BindingFlags.InvokeMethod, null, paragraphs,
1265                        new object[] { item.ParagraphIndex });
1266
1267                    object range = para.GetType().InvokeMember("Range",
1268                        System.Reflection.BindingFlags.GetProperty, null, para, null);
1269
1270                    if (item.IsCaption)
1271                    {
1272                        // For captions, only replace text after "Рисунок N — " prefix
1273                        // Find the dash separator in the range text
1274                        string rangeText = (string)range.GetType().InvokeMember("Text",
1275                            System.Reflection.BindingFlags.GetProperty, null, range, null);
1276
1277                        // Find position of " — " or " - " or " – "
1278                        int dashPos = -1;
1279                        string[] dashPatterns = { " — ", " – ", " - " };
1280                        string foundDash = null;
1281                        foreach (var dp in dashPatterns)
1282                        {
1283                            dashPos = rangeText.IndexOf(dp);
1284                            if (dashPos >= 0) { foundDash = dp; break; }
1285                        }
1286
1287                        if (dashPos >= 0 && foundDash != null)
1288                        {
1289                            int textStart = dashPos + foundDash.Length;
1290                            // Get range start position
1291                            int rangeStart = (int)range.GetType().InvokeMember("Start",
1292                                System.Reflection.BindingFlags.GetProperty, null, range, null);
1293
1294                            int rangeEnd = (int)range.GetType().InvokeMember("End",
1295                                System.Reflection.BindingFlags.GetProperty, null, range, null);
1296
1297                            // Create a new range for just the text portion
1298                            object textRange = doc.GetType().InvokeMember("Range",
1299                                System.Reflection.BindingFlags.InvokeMethod, null, doc,
1300                                new object[] { rangeStart + textStart, rangeEnd - 1 }); // -1 to exclude paragraph mark
1301
1302                            // Replace text
1303                            textRange.GetType().InvokeMember("Text",
1304                                System.Reflection.BindingFlags.SetProperty, null, textRange,
1305                                new object[] { item.NewText });
1306
1307                            replacedCount++;
1308                            Log("Replaced caption [" + item.ParagraphIndex + "]: " + item.NewText.Substring(0, Math.Min(40, item.NewText.Length)));
1309                        }
1310                        else
1311                        {
1312                            Log("Could not find dash separator in caption at paragraph " + item.ParagraphIndex);
1313                        }
1314                    }
1315                    else
1316                    {
1317                        // For descriptions, replace entire text and reapply formatting
1318                        int rangeStart = (int)range.GetType().InvokeMember("Start",
1319                            System.Reflection.BindingFlags.GetProperty, null, range, null);
1320                        int rangeEnd = (int)range.GetType().InvokeMember("End",
1321                            System.Reflection.BindingFlags.GetProperty, null, range, null);
1322
1323                        // Create range excluding paragraph mark
1324                        object textRange = doc.GetType().InvokeMember("Range",
1325                            System.Reflection.BindingFlags.InvokeMethod, null, doc,
1326                            new object[] { rangeStart, rangeEnd - 1 });
1327
1328                        textRange.GetType().InvokeMember("Text",
1329                            System.Reflection.BindingFlags.SetProperty, null, textRange,
1330                            new object[] { item.NewText });
1331
1332                        // Reapply formatting: TNR 14, justified, 1.5 spacing, indent 1.25cm
1333                        // Re-get the paragraph range after text change
1334                        para = paragraphs.GetType().InvokeMember("Item",
1335                            System.Reflection.BindingFlags.InvokeMethod, null, paragraphs,
1336                            new object[] { item.ParagraphIndex });
1337                        range = para.GetType().InvokeMember("Range",
1338                            System.Reflection.BindingFlags.GetProperty, null, para, null);
1339
1340                        object font = range.GetType().InvokeMember("Font",
1341                            System.Reflection.BindingFlags.GetProperty, null, range, null);
1342                        font.GetType().InvokeMember("Name",
1343                            System.Reflection.BindingFlags.SetProperty, null, font, new object[] { "Times New Roman" });
1344                        font.GetType().InvokeMember("Size",
1345                            System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 14f });
1346                        font.GetType().InvokeMember("Color",
1347                            System.Reflection.BindingFlags.SetProperty, null, font, new object[] { 0 });
1348
1349                        object paragraphFormat = para.GetType().InvokeMember("Format",
1350                            System.Reflection.BindingFlags.GetProperty, null, para, null);
1351                        paragraphFormat.GetType().InvokeMember("Alignment",
1352                            System.Reflection.BindingFlags.SetProperty, null, paragraphFormat,
1353                            new object[] { wdAlignParagraphJustify });
1354                        paragraphFormat.GetType().InvokeMember("LineSpacingRule",
1355                            System.Reflection.BindingFlags.SetProperty, null, paragraphFormat,
1356                            new object[] { wdLineSpaceMultiple });
1357                        paragraphFormat.GetType().InvokeMember("LineSpacing",
1358                            System.Reflection.BindingFlags.SetProperty, null, paragraphFormat,
1359                            new object[] { 18f }); // 1.5 lines
1360                        paragraphFormat.GetType().InvokeMember("FirstLineIndent",
1361                            System.Reflection.BindingFlags.SetProperty, null, paragraphFormat,
1362                            new object[] { 35.4f }); // 1.25cm
1363
1364                        replacedCount++;
1365                        Log("Replaced description [" + item.ParagraphIndex + "]: " + item.NewText.Substring(0, Math.Min(40, item.NewText.Length)));
1366                    }
1367                }
1368                catch (Exception ex)
1369                {
1370                    Log("Error replacing paragraph " + item.ParagraphIndex + ": " + ex.Message);
1371                }
1372            }
1373
1374            Log("Total replaced: " + replacedCount);
1375            return replacedCount;
1376        }
1377
1378        /// <summary>
1379        /// Release COM objects
1380        /// </summary>
1381        public static void Cleanup()
1382        {
1383            try
1384            {
1385                if (selectedDocument != null && selectedDocument.DocumentObject != null)
1386                {
1387                    try { Marshal.ReleaseComObject(selectedDocument.DocumentObject); } catch { }
1388                    selectedDocument = null;
1389                }
1390                if (wordApp != null)
1391                {
1392                    try { Marshal.ReleaseComObject(wordApp); } catch { }
1393                    wordApp = null;
1394                }
1395            }
1396            catch { }
1397
1398            // The hot paths obtain many short-lived COM objects (Selection/Range/Font/Paragraphs…)
1399            // via late-binding InvokeMember. Those RCWs aren't released deterministically, so without
1400            // this Word.exe lingers in memory after the user closes it. Forcing the GC to finalize the
1401            // now-unreferenced RCWs releases their underlying COM references (standard Office-interop fix).
1402            try
1403            {
1404                GC.Collect();
1405                GC.WaitForPendingFinalizers();
1406                GC.Collect();
1407            }
1408            catch { }
1409        }
1410    }
1411}