diff --git a/.github/release/licenses/OpenXmlSdk-MIT.txt b/.github/release/licenses/OpenXmlSdk-MIT.txt new file mode 100644 index 0000000..114db7b --- /dev/null +++ b/.github/release/licenses/OpenXmlSdk-MIT.txt @@ -0,0 +1,25 @@ +DocumentFormat.OpenXml и DocumentFormat.OpenXml.Framework (Open XML SDK) — https://github.com/dotnet/Open-XML-SDK + +The MIT License (MIT) + +Copyright (c) .NET Foundation and Contributors + +All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da9459..6213879 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # История версий SignService +## v1.4.0 — 20.10.2026 + +- **Штамп в документах Word (.docx, .docm).** С галочкой «Штамп на документ» + документ Word получает ту же плашку, что и PDF: копия «имя (со штампом).docx» + с плашкой на каждого подписанта (сертификат, владелец, срок, МЧД, дата, логотип). + Страниц в файле Word нет, поэтому «последняя» и «указанные» — плашки в конце + текста, «первая» — в нижнем колонтитуле первой страницы (остальные страницы + и разделы не меняются), «все» — в нижних колонтитулах всех страниц. + Работают оба режима: копия без подписи и «подписывать копию со штампом». + Инструмент «Штамп на PDF/Word…» и пункт контекстного меню — тоже для Word. + Для .doc/.rtf и других форматов в логе пишется, почему штампа нет. +- **Плашки — только по настоящим подписям документа.** Плашка «Документ подписан» + ставится лишь по подписи, которая проверяется по этому документу; подпись под + другой версией файла больше не превращается в плашку. Дата на плашке — время + подписания из самой подписи, номер МЧД — только у того, кто подписывал по ней + (раньше всем подписантам ставились текущие дата и МЧД). «Штамп» без подписания + находит подписи документа по содержимому и работает без выбранного сертификата. +- **Подписанная копия со штампом не затирается.** Если у «… (со штампом).pdf» + уже есть своя подпись, программа не пересоздаёт копию и объясняет, как быть. + Сбой создания копии после записи подписи — предупреждение в строке файла, + а не «Ошибка» подписания. PDF-копия пишется атомарно. +- **Пакет подписания «заморожен».** Пока идёт подписание, параметры подписи, + сертификат, МЧД, «Применить ко всем», настройки и состав очереди не меняются — + раньше их можно было переключить после подтверждения рисков. Подписи, + перетащенные во время подписания, не прикладываются молча к уже подписываемым + файлам. Кнопка «Добавить доверенность…» больше не залипает. +- Перетаскивание папки с недоступной подпапкой больше не обрывается ошибкой. +- System.Security.Cryptography.Pkcs 8.0.2 — исправления уязвимостей + GHSA-846f-f764-76mx и GHSA-2fmv-rv2c-59x7. + ## v1.3.3 — 12.10.2026 - **Резервная копия .sig.** В «Настройках…» — галочка «Всегда создавать diff --git a/README.md b/README.md index e5023f5..b701f59 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ исключить нельзя; - **Собрать контейнер…** — документ упаковывается в прикреплённый `.sig` вместе с имеющимися подписями (обратная операция к извлечению); -- **Штамп на PDF…** — копия документа с плашкой о подписании без подписи. +- **Штамп на PDF/Word…** — копия документа с плашкой о подписании без подписи. ### Доверенность (МЧД) @@ -96,25 +96,34 @@ (или ещё не действовавший) — ошибка; - при подписании файлы МЧД (XML + .sig) копируются рядом с подписанным документом (в CMS-подпись МЧД не вкладывается — так же поступает Контур), - а в визуальный штамп на PDF добавляется строка + а в визуальный штамп добавляется строка «Действует на основании МЧД № …»; - доверенность запоминается и перепроверяется в фоне при запуске и перед каждым пакетным подписанием (один раз на пакет). -### Визуальный штамп на PDF +### Визуальный штамп на PDF и Word - плашка «ДОКУМЕНТ ПОДПИСАН ЭЛЕКТРОННОЙ ПОДПИСЬЮ»: номер сертификата, владелец, срок действия, по желанию — дата подписания и **логотип организации** (PNG/JPEG); - параметры выбираются в диалоге перед операцией: страницы (первая, последняя, все или указанные, например «1,3-5»), дата, логотип; на каждой выбранной странице — **отдельная плашка на каждого подписанта**; -- по умолчанию подписывается **оригинал**, а копия «имя (со штампом).pdf» — - отдельный файл **без подписи**, который после каждого подписания - пересоздаётся со **всеми подписантами** итоговой подписи (безопасно - при соподписании: штамп не делает подписи недействительными); +- штамп ставится на PDF и документы **Word (.docx, .docm)**; в Word плашки — + таблицы в рамке у правого края. Страниц в файле Word нет (их считает + программа просмотра), поэтому «последняя» и «указанные» — плашки в конце + текста, «первая» — в нижнем колонтитуле первой страницы (остальные страницы + и разделы не меняются), «все» — в нижних колонтитулах всех страниц; + старые форматы .doc/.rtf подписываются без штампа — сохраните их как .docx; +- по умолчанию подписывается **оригинал**, а копия «имя (со штампом).pdf» + («имя (со штампом).docx») — отдельный файл **без подписи**, который после + каждого подписания пересоздаётся со **всеми подписантами** итоговой подписи + (безопасно при соподписании: штамп не делает подписи недействительными); - отдельной галочкой доступен режим «подписывать копию со штампом» (штамп до подписи) — для случаев, когда принимающая сторона требует - подпись именно под штампованным документом. + подпись именно под штампованным документом; +- копия со штампом, у которой уже есть своя подпись («… (со штампом).pdf.sig»), + не перезаписывается: программа откажется и подскажет, что делать + (добавить подпись к самой копии — подписать её без штампа). ### Сертификаты @@ -209,7 +218,7 @@ dotnet publish src/SignService -c Release -r win-x64 --self-contained true \ 1. Запустите программу — в списке появятся сертификаты с закрытым ключом. 2. Выберите сертификат, режим подписи и «Добавить к существующей подписи» или «Создать новую»; у каждого файла видно, к какой подписи добавится ваша. - При необходимости включите штамп на PDF (параметры спросит диалог), + При необходимости включите штамп на PDF или Word (параметры спросит диалог), штамп времени — в «Настройках…». 3. Перетащите файлы в окно (или «Обзор…») и нажмите **«Подписать»** — рядом с каждым файлом появится `имя.sig`. @@ -248,6 +257,8 @@ src/SignService/ │ ├── CertificateVault.cs # сертификат на ПК: PFX и хранилище Windows │ ├── TimestampClient.cs # штамп времени TSA (RFC 3161) для CAdES-T │ ├── PdfStamper.cs # визуальный штамп на PDF (страницы, подписанты, лого) +│ ├── WordStamper.cs # тот же штамп в документе Word (.docx, .docm) +│ ├── DocumentStamper.cs # выбор штампа по формату, защита подписанной копии │ ├── UpdateService.cs # автообновление через GitHub Releases │ └── AppSettings.cs # настройки (сертификат, режимы, штампы, доверие) ├── ViewModels/ # MVVM: главное окно, файлы, сертификаты diff --git a/src/SignService/Services/DocumentSigner.cs b/src/SignService/Services/DocumentSigner.cs index 3b2c3d8..5cfa01e 100644 --- a/src/SignService/Services/DocumentSigner.cs +++ b/src/SignService/Services/DocumentSigner.cs @@ -58,7 +58,7 @@ public sealed record SignOptions /// Адрес службы штампов времени (RFC 3161). public string? TsaUrl { get; init; } - /// Ставить ли визуальный штамп о подписании на PDF-документ. + /// Ставить ли визуальный штамп о подписании (PDF и Word .docx/.docm). public bool Stamp { get; init; } /// @@ -84,7 +84,7 @@ public sealed record SignOptions /// /// Доверенность МЧД: файлы XML и подписи руководителя копируются в каталог - /// подписанного документа (как это делает Контур), номер попадает в PDF-штамп. + /// подписанного документа (как это делает Контур), номер попадает в штамп. /// public PowerOfAttorneyService.PoaInfo? PowerOfAttorney { get; init; } @@ -101,7 +101,9 @@ public sealed record SignOptions /// Результат подписания файла: подписанный документ (в режиме «подписывать /// копию со штампом» — путь к ней), путь к .sig, число подписантов, /// исключённые при объединении подписи, не проверенные подписи и путь - /// к штампованной копии БЕЗ подписи (режим «копия отдельно»). + /// к штампованной копии БЕЗ подписи (режим «копия отдельно»). StampWarning — + /// подпись сохранена, но с копией со штампом что-то не так (не создана или + /// не все подписанты попали на плашки). /// public readonly record struct SignFileResult( string SignedDocumentPath, @@ -112,7 +114,8 @@ public readonly record struct SignFileResult( string? StampedCopyPath = null, int PreviousSignerCount = 0, bool AppendedToExisting = false, - string? BackupPath = null); + string? BackupPath = null, + string? StampWarning = null); private readonly TimestampClient _timestampClient = new(); @@ -156,12 +159,16 @@ public async Task SignFileAsync( if (poaPackage?.Check.State == PowerOfAttorneyService.CheckState.Error) throw new InvalidOperationException(poaPackage.Check.Message); if (poaPackage is not null) PowerOfAttorneyService.CheckCopyTargets(poaPackage, filePath); - // Режим «подписывать копию со штампом»: штамп меняет содержимое PDF, + // Режим «подписывать копию со штампом»: штамп меняет содержимое документа, // поэтому копия создаётся ДО подписания и подписывается именно она. // В режиме «копия отдельно» подписывается ОРИГИНАЛ, а штампованная копия // (без подписи, со всеми подписантами) создаётся ниже, после подписания. var targetPath = filePath; - var stampAsCopy = options.Stamp && !options.StampSignCopy && PdfStamper.IsPdf(filePath); + var stampAsCopy = options.Stamp && !options.StampSignCopy && DocumentStamper.IsSupported(filePath); + // Подписанную копию со штампом не трогаем — проверка до подписания, чтобы + // не получилось «документ подписан, а копию сделать нельзя». + if (options.Stamp && DocumentStamper.IsSupported(filePath)) + DocumentStamper.ThrowIfCopySigned(filePath); var stampOptions = options.StampParameters ?? new PdfStamper.StampOptions { WithDate = options.StampWithDate, LogoPath = options.StampLogoPath }; // Номер МЧД — из проверенного снимка файлов, а не из сохранённых ранее сведений. @@ -169,18 +176,20 @@ public async Task SignFileAsync( { PoaNumber = stampOptions.PoaNumber ?? poaPackage?.Info.Number, }; - if (options.Stamp && options.StampSignCopy && PdfStamper.IsPdf(filePath)) + if (options.Stamp && options.StampSignCopy && DocumentStamper.IsSupported(filePath)) { try { - targetPath = PdfStamper.CreateStampedCopy( - filePath, new[] { certificate }, stampOptions, DateTime.Now); + targetPath = DocumentStamper.CreateStampedCopy( + filePath, new[] { new PdfStamper.StampSigner(certificate, DateTime.Now, stampOptions.PoaNumber) }, + stampOptions); } catch (Exception e) { + var format = DocumentStamper.FormatName(filePath); throw new InvalidOperationException( - $"Не удалось поставить штамп на PDF ({e.Message}). " + - "Проверьте, что файл — корректный незашифрованный PDF, либо отключите штамп.", e); + $"Не удалось поставить штамп на {format} ({e.Message}). " + + $"Проверьте, что файл — корректный незашифрованный {format}, либо отключите штамп.", e); } } @@ -293,33 +302,39 @@ void AddInputPath(string? path) if (poaPackage is not null) PowerOfAttorneyService.CopyPackage(poaPackage, targetPath); await AtomicFile.WriteAsync(signaturePath, output, overwrite: true, cancellationToken); - // Режим «копия отдельно»: штампованная копия БЕЗ подписи, со всеми - // подписантами итогового .sig — пересоздаётся после каждого подписания. + // Режим «копия отдельно»: штампованная копия БЕЗ подписи с подписантами + // итогового .sig — пересоздаётся после каждого подписания. Плашки — только + // по подписям, которые проверяются по документу; дата у каждого своя, + // номер МЧД — у того, кто подписывает сейчас. Подпись к этому моменту уже + // записана, поэтому сбой копии — предупреждение, а не ошибка подписания. string? stampedCopyPath = null; + string? stampWarning = null; if (stampAsCopy) { try { - var signerCerts = CmsMerger.GetSignerCertificates( - await File.ReadAllBytesAsync(signaturePath, cancellationToken)); - var stampCerts = signerCerts.Count > 0 - ? signerCerts - : new List { certificate }; - stampedCopyPath = PdfStamper.CreateStampedCopy( - filePath, stampCerts, stampOptions, DateTime.Now); - foreach (var c in signerCerts) - c.Dispose(); + using var found = DocumentStamper.SignersFromSignatures(new[] { output }, data); + var signers = found.Signers + .Select(s => DocumentStamper.SameCertificate(s.Certificate, certificate) + ? s with { PoaNumber = stampOptions.PoaNumber } + : s) + .ToList(); + if (!signers.Any(s => DocumentStamper.SameCertificate(s.Certificate, certificate))) + signers.Add(new PdfStamper.StampSigner(certificate, DateTime.Now, stampOptions.PoaNumber)); + stampedCopyPath = DocumentStamper.CreateStampedCopy(filePath, signers, stampOptions); + if (found.Skipped.Count > 0) + stampWarning = "в копии со штампом нет плашек для подписей, которые не удалось проверить по документу: " + + string.Join("; ", found.Skipped); } catch (Exception e) { - throw new InvalidOperationException( - $"Документ подписан, но не удалось создать копию со штампом ({e.Message}).", e); + stampWarning = $"подпись сохранена, но копию со штампом создать не удалось: {e.Message}"; } } return new SignFileResult( targetPath, signaturePath, signerCount, excluded, unverified, stampedCopyPath, - previousSignerCount, appended, backupPath); + previousSignerCount, appended, backupPath, stampWarning); } /// diff --git a/src/SignService/Services/DocumentStamper.cs b/src/SignService/Services/DocumentStamper.cs new file mode 100644 index 0000000..a48c41c --- /dev/null +++ b/src/SignService/Services/DocumentStamper.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; + +namespace SignService.Services; + +/// +/// Визуальный штамп на копии документа по его формату: PDF — , +/// Word (.docx, .docm) — . Остальные форматы подписываются без штампа. +/// +public static class DocumentStamper +{ + public static bool IsSupported(string filePath) => + PdfStamper.IsPdf(filePath) || WordStamper.IsWord(filePath); + + /// «имя (со штампом).pdf» или «имя (со штампом).docx» рядом с документом. + public static string StampedCopyPath(string filePath) => + WordStamper.IsWord(filePath) ? WordStamper.StampedCopyPath(filePath) : PdfStamper.StampedCopyPath(filePath); + + /// + /// Отказ, если у копии со штампом есть своя подпись («… (со штампом).pdf.sig»): + /// новая копия — другой файл, и та подпись перестала бы ему соответствовать. + /// + public static void ThrowIfCopySigned(string filePath) + { + var copy = StampedCopyPath(filePath); + if (!File.Exists(copy + ".sig")) + return; + var name = Path.GetFileName(copy); + throw new InvalidOperationException( + $"Копия «{name}» уже подписана («{name}.sig»), новая копия со штампом сделала бы эту подпись " + + $"недействительной. Чтобы добавить подпись к копии, подпишите сам файл «{name}» без штампа; " + + "чтобы сделать копию заново, удалите или переименуйте её файл подписи."); + } + + /// + /// Создаёт «имя (со штампом).pdf» или «имя (со штампом).docx» рядом с документом. + /// Подписанная копия не перезаписывается (см. ). + /// + public static string CreateStampedCopy( + string filePath, + IReadOnlyList signers, + PdfStamper.StampOptions options) + { + ThrowIfCopySigned(filePath); + if (WordStamper.IsWord(filePath)) + return WordStamper.CreateStampedCopy(filePath, signers, options); + if (PdfStamper.IsPdf(filePath)) + return PdfStamper.CreateStampedCopy(filePath, signers, options); + throw new InvalidOperationException(UnsupportedMessage(filePath)); + } + + /// Все сертификаты — с одной датой и МЧД из параметров. + public static string CreateStampedCopy( + string filePath, + IReadOnlyList certificates, + PdfStamper.StampOptions options, + DateTime signTime) + => CreateStampedCopy(filePath, PdfStamper.ToSigners(certificates, options, signTime), options); + + /// + /// Подписанты для плашек из файлов подписи документа. Плашка «Документ подписан» + /// ставится только по подписи, которая криптографически проверяется по этому + /// документу; остальные (другая версия документа, не удалось проверить) — в Skipped. + /// Время — из атрибута signingTime подписи. Сертификаты освобождаются в Dispose. + /// + public static StampSigners SignersFromSignatures(IEnumerable signatures, byte[] document) + { + var signers = new List(); + var valid = new HashSet(StringComparer.OrdinalIgnoreCase); + var skipped = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var signature in signatures) + { + var report = SignatureVerifier.Verify(signature, document, VerificationOptions.CryptographyOnly); + foreach (var signer in report.Signers) + { + if (signer.CryptographicallyValid && signer.Certificate is { } raw) + { + if (valid.Add(signer.Thumbprint)) + signers.Add(new PdfStamper.StampSigner( + new X509Certificate2(raw), signer.SigningTime?.ToLocalTime().DateTime)); + } + else + { + var key = signer.Thumbprint.Length > 0 ? signer.Thumbprint : $"{skipped.Count}:{signer.Name}"; + skipped.TryAdd(key, DisplayName(signer)); + } + } + } + + // Подпись, проверенная в одном файле, не считается пропущенной из-за другого. + return new StampSigners(signers, + skipped.Where(pair => !valid.Contains(pair.Key)).Select(pair => pair.Value).Distinct().ToList()); + } + + private static string DisplayName(SignerVerification signer) + { + if (signer.Certificate is not { } raw) + return signer.Name; + try + { + using var certificate = new X509Certificate2(raw); + return CertificateProvider.GetSubjectName(certificate); + } + catch (System.Security.Cryptography.CryptographicException) + { + return signer.Name; + } + } + + /// Тот же владелец ключа (сравнение по отпечатку сертификата). + public static bool SameCertificate(X509Certificate2 left, X509Certificate2 right) => + string.Equals(left.Thumbprint, right.Thumbprint, StringComparison.OrdinalIgnoreCase); + + /// Подписанты для плашек и имена тех, чьи подписи к документу не подошли. + public sealed class StampSigners : IDisposable + { + public StampSigners(IReadOnlyList signers, IReadOnlyList skipped) + { + Signers = signers; + Skipped = skipped; + } + + public IReadOnlyList Signers { get; } + + public IReadOnlyList Skipped { get; } + + public void Dispose() + { + foreach (var signer in Signers) + signer.Certificate.Dispose(); + } + } + + /// + /// Пояснение для лога: почему штампа нет или почему он стоит не на выбранных + /// страницах (Word). null — штамп поставлен так, как выбрано. + /// + public static string? Note(string filePath, PdfStamper.StampOptions options) => + WordStamper.IsWord(filePath) ? WordStamper.PlacementNote(options) + : PdfStamper.IsPdf(filePath) ? null + : UnsupportedMessage(filePath); + + /// Как назвать формат в сообщении об ошибке штампа. + public static string FormatName(string filePath) => + WordStamper.IsWord(filePath) ? "документ Word" : "PDF"; + + private static string UnsupportedMessage(string filePath) + { + var extension = Path.GetExtension(filePath).ToLowerInvariant(); + return extension is ".doc" or ".rtf" or ".odt" + ? $"штамп не ставится: формат {extension} не поддерживается — сохраните документ как .docx" + : "штамп не ставится: поддерживаются PDF и документы Word (.docx, .docm)"; + } +} diff --git a/src/SignService/Services/PdfStamper.cs b/src/SignService/Services/PdfStamper.cs index cf46e99..81b4d7a 100644 --- a/src/SignService/Services/PdfStamper.cs +++ b/src/SignService/Services/PdfStamper.cs @@ -28,6 +28,11 @@ static PdfStamper() public static bool IsPdf(string filePath) => Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase); + /// «имя (со штампом).pdf» рядом с документом. + public static string StampedCopyPath(string pdfPath) => + Path.Combine(Path.GetDirectoryName(pdfPath) ?? ".", + $"{Path.GetFileNameWithoutExtension(pdfPath)} (со штампом).pdf"); + /// На каких страницах ставить штампы. public enum StampPages { @@ -52,6 +57,17 @@ public sealed record StampOptions public string? CustomPages { get; init; } } + /// + /// Подписант на плашке: сертификат, время подписания (null — строки с датой нет) + /// и номер МЧД, по которой подписывал именно он. + /// + public sealed record StampSigner(X509Certificate2 Certificate, DateTime? SignTime, string? PoaNumber = null); + + /// Все сертификаты с одной датой и МЧД из параметров (для вызовов без сведений о подписях). + internal static IReadOnlyList ToSigners( + IReadOnlyList certificates, StampOptions options, DateTime signTime) => + certificates.Select(c => new StampSigner(c, signTime, options.PoaNumber)).ToList(); + /// Совместимая обёртка: один подписант, штамп на последней странице. public static string CreateStampedCopy( string pdfPath, @@ -84,23 +100,28 @@ public static string CreateStampedCopy( IReadOnlyList certificates, StampOptions options, DateTime signTime) + => CreateStampedCopy(pdfPath, ToSigners(certificates, options, signTime), options); + + /// Копия со штампом: по плашке на подписанта, со своими датой и МЧД. + public static string CreateStampedCopy(string pdfPath, IReadOnlyList signers, StampOptions options) { - if (certificates.Count == 0) - throw new ArgumentException("Нет сертификатов для штампа.", nameof(certificates)); + if (signers.Count == 0) + throw new ArgumentException("Нет подписантов для штампа.", nameof(signers)); - var directory = Path.GetDirectoryName(pdfPath) ?? "."; - var stem = Path.GetFileNameWithoutExtension(pdfPath); - var outputPath = Path.Combine(directory, $"{stem} (со штампом).pdf"); + var outputPath = StampedCopyPath(pdfPath); using var document = PdfReader.Open(pdfPath, PdfDocumentOpenMode.Modify); foreach (var pageIndex in ResolvePages(options, document.Pages.Count)) { var page = document.Pages[pageIndex]; using var gfx = XGraphics.FromPdfPage(page); - DrawStampsOnPage(gfx, page, certificates, options, signTime); + DrawStampsOnPage(gfx, page, signers, options); } - document.Save(outputPath); + // Сначала в память, затем атомарно: сбой записи не оставит обрезанный PDF. + using var buffer = new MemoryStream(); + document.Save(buffer, closeStream: false); + AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true); return outputPath; } @@ -139,8 +160,7 @@ public static IReadOnlyList ResolvePages(StampOptions options, int pageCoun // Раскладка: плашки подписантов колонками из правого нижнего угла вверх, // при нехватке высоты — следующая колонка левее. private static void DrawStampsOnPage( - XGraphics gfx, PdfPage page, IReadOnlyList certificates, - StampOptions options, DateTime signTime) + XGraphics gfx, PdfPage page, IReadOnlyList signers, StampOptions options) { const double width = 250; const double margin = 20; @@ -149,9 +169,9 @@ private static void DrawStampsOnPage( var x = page.Width.Point - width - margin; var y = page.Height.Point - margin; - foreach (var certificate in certificates) + foreach (var signer in signers) { - var height = ComputeStampHeight(certificate, options); + var height = ComputeStampHeight(signer, options); if (y - height < margin && y < page.Height.Point - margin) { // колонка заполнена — следующая левее @@ -160,21 +180,30 @@ private static void DrawStampsOnPage( } y -= height; - DrawSingleStamp(gfx, new XRect(x, y, width, height), certificate, options, signTime); + DrawSingleStamp(gfx, new XRect(x, y, width, height), signer, options); y -= gap; } } - private static List StampLines(X509Certificate2 certificate, StampOptions options) + internal const string TitleLine1 = "ДОКУМЕНТ ПОДПИСАН"; + internal const string TitleLine2 = "ЭЛЕКТРОННОЙ ПОДПИСЬЮ"; + + internal static string DateLine(DateTime signTime) => $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}"; + + /// Строки плашки (общие для PDF и Word): сертификат, владелец, срок, МЧД, дата. + internal static List StampLines(StampSigner signer, StampOptions options) { + var certificate = signer.Certificate; var lines = new List { $"Сертификат: {certificate.SerialNumber}", $"Владелец: {CertificateProvider.GetSubjectName(certificate)}", $"Действителен: с {certificate.NotBefore:dd.MM.yyyy} по {certificate.NotAfter:dd.MM.yyyy}", }; - if (!string.IsNullOrWhiteSpace(options.PoaNumber)) - lines.Add($"Действует на основании МЧД № {options.PoaNumber}"); + if (!string.IsNullOrWhiteSpace(signer.PoaNumber)) + lines.Add($"Действует на основании МЧД № {signer.PoaNumber}"); + if (options.WithDate && signer.SignTime is { } signTime) + lines.Add(DateLine(signTime)); return lines; } @@ -182,15 +211,15 @@ private static List StampLines(X509Certificate2 certificate, StampOption private const double StampLineHeight = 10.5; private const double StampTitleHeight = 24; - private static double ComputeStampHeight(X509Certificate2 certificate, StampOptions options) + private static double ComputeStampHeight(StampSigner signer, StampOptions options) { var logoSize = options.LogoPath is not null ? 34.0 : 0.0; - var bodyLines = StampLines(certificate, options).Count + (options.WithDate ? 1 : 0); + var bodyLines = StampLines(signer, options).Count; return StampPad * 2 + Math.Max(StampTitleHeight, logoSize) + 4 + bodyLines * StampLineHeight; } private static void DrawSingleStamp( - XGraphics gfx, XRect rect, X509Certificate2 certificate, StampOptions options, DateTime signTime) + XGraphics gfx, XRect rect, StampSigner signer, StampOptions options) { var width = rect.Width; var blue = XColor.FromArgb(0x1B, 0x4F, 0x9C); @@ -198,8 +227,7 @@ private static void DrawSingleStamp( var titleFont = new XFont("stamp", 8, XFontStyleEx.Bold); var textFont = new XFont("stamp", 7, XFontStyleEx.Regular); - var lines = StampLines(certificate, options); - var dateLine = options.WithDate ? $"Дата подписания: {signTime:dd.MM.yyyy HH:mm}" : null; + var lines = StampLines(signer, options); var logoPath = options.LogoPath; const double pad = StampPad; @@ -232,15 +260,15 @@ private static void DrawSingleStamp( // Заголовок var titleRect = new XRect(contentX, topY, x + width - pad - contentX, titleHeight + 4); - gfx.DrawString("ДОКУМЕНТ ПОДПИСАН", titleFont, new XSolidBrush(blue), + gfx.DrawString(TitleLine1, titleFont, new XSolidBrush(blue), new XRect(titleRect.X, titleRect.Y, titleRect.Width, 12), XStringFormats.TopLeft); - gfx.DrawString("ЭЛЕКТРОННОЙ ПОДПИСЬЮ", titleFont, new XSolidBrush(blue), + gfx.DrawString(TitleLine2, titleFont, new XSolidBrush(blue), new XRect(titleRect.X, titleRect.Y + 11, titleRect.Width, 12), XStringFormats.TopLeft); // Текст var textBrush = new XSolidBrush(blue); var textY = topY + Math.Max(titleHeight, logoSize) + 4; - foreach (var line in dateLine is null ? lines : lines.Append(dateLine)) + foreach (var line in lines) { gfx.DrawString(Truncate(gfx, line, textFont, width - pad * 2), textFont, textBrush, new XRect(x + pad, textY, width - pad * 2, lineHeight), XStringFormats.TopLeft); @@ -263,7 +291,9 @@ private static void DrawSingleStamp( try { - using var bitmap = SkiaSharp.SKBitmap.Decode(path); + // из потока: путь с кириллицей не зависит от того, как Skia открывает файлы + using var file = File.OpenRead(path); + using var bitmap = SkiaSharp.SKBitmap.Decode(file); if (bitmap is null) return null; diff --git a/src/SignService/Services/WordStamper.cs b/src/SignService/Services/WordStamper.cs new file mode 100644 index 0000000..862dbb1 --- /dev/null +++ b/src/SignService/Services/WordStamper.cs @@ -0,0 +1,644 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Wordprocessing; +using SkiaSharp; +using A = DocumentFormat.OpenXml.Drawing; +using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; +using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + +namespace SignService.Services; + +/// +/// Визуальный штамп в документе Word (.docx, .docm): те же плашки, что на PDF +/// («Документ подписан электронной подписью», сертификат, владелец, срок, МЧД, +/// дата, логотип), — таблицами в рамке, по плашке на подписанта, у правого края. +/// Страниц в файле Word нет (их считает программа просмотра), поэтому: +/// «последняя» и «указанные» — плашки в конце текста, «первая» — в нижнем +/// колонтитуле первой страницы, «все» — в нижних колонтитулах всех страниц. +/// Исходный файл не меняется: создаётся копия «имя (со штампом).docx». +/// +public static class WordStamper +{ + private const string Blue = "1B4F9C"; + private const string FontName = "Arial"; + private const string TitleSize = "16"; // 8 pt, в полупунктах + private const string TextSize = "14"; // 7 pt + private const uint BorderSize = 10; // 1,25 pt, в восьмых долях пункта + + // Размеры — в twips (1/20 пункта). + private const int MinStampWidth = 3969; // 7 см + private const int MaxStampWidth = 5000; // ≈ 8,8 см — как плашка на PDF (250 pt) + private const int StampGap = 113; // 2 мм между плашками + private const int CellPadding = 113; + private const int LogoTwips = 680; // 34 pt — как на PDF + private const long EmuPerTwip = 635; + private const int MaxLogoPixels = 400; + + private const string Word2010Namespace = "http://schemas.microsoft.com/office/word/2010/wordml"; + + public static bool IsWord(string filePath) => + Path.GetExtension(filePath).ToLowerInvariant() is ".docx" or ".docm"; + + /// «имя (со штампом).docx» рядом с документом (расширение сохраняется). + public static string StampedCopyPath(string filePath) => + Path.Combine(Path.GetDirectoryName(filePath) ?? ".", + $"{Path.GetFileNameWithoutExtension(filePath)} (со штампом){Path.GetExtension(filePath)}"); + + /// Пояснение, если выбранные страницы в Word отработаны иначе, чем на PDF. + public static string? PlacementNote(PdfStamper.StampOptions options) => + options.Pages == PdfStamper.StampPages.Custom + ? "в Word страницы не выбираются по номерам — штамп поставлен в конце текста" + : null; + + /// + /// Создаёт копию «имя (со штампом).docx» (существующая заменяется) с плашками + /// всех переданных подписантов. Копия пишется атомарно; исходник только читается. + /// + public static string CreateStampedCopy( + string docPath, + IReadOnlyList certificates, + PdfStamper.StampOptions options, + DateTime signTime) + => CreateStampedCopy(docPath, PdfStamper.ToSigners(certificates, options, signTime), options); + + /// Копия со штампом: по плашке на подписанта, со своими датой и МЧД. + public static string CreateStampedCopy( + string docPath, IReadOnlyList signers, PdfStamper.StampOptions options) + { + if (signers.Count == 0) + throw new ArgumentException("Нет подписантов для штампа.", nameof(signers)); + + var outputPath = StampedCopyPath(docPath); + using var buffer = new MemoryStream(); + using (var source = new FileStream(docPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + source.CopyTo(buffer); + + using (var document = WordprocessingDocument.Open(buffer, true)) + { + var stamp = new Stamp(document, signers, options); + switch (options.Pages) + { + case PdfStamper.StampPages.First: + stamp.IntoFirstPageFooter(); + break; + case PdfStamper.StampPages.All: + stamp.IntoAllFooters(); + break; + default: + stamp.IntoEndOfText(); + break; + } + } + + AtomicFile.Write(outputPath, buffer.ToArray(), overwrite: true); + return outputPath; + } + + private sealed record Logo(byte[] Png, int Width, int Height); + + /// Штамп для одного документа: разметка плашек и правка колонтитулов. + private sealed class Stamp + { + private readonly MainDocumentPart _main; + private readonly Body _body; + private readonly IReadOnlyList _signers; + private readonly PdfStamper.StampOptions _options; + private readonly Logo? _logo; + private readonly Dictionary _logoIds = new(); + private uint _nextDrawingId; + + public Stamp(WordprocessingDocument document, IReadOnlyList signers, + PdfStamper.StampOptions options) + { + _main = document.MainDocumentPart + ?? throw new InvalidOperationException("В файле нет основной части документа Word."); + _body = _main.Document?.Body + ?? throw new InvalidOperationException("В документе Word нет текста."); + _signers = signers; + _options = options; + _logo = LoadLogo(options.LogoPath); + _nextDrawingId = MaxDrawingId() + 1; + } + + /// «Последняя» и «указанные»: плашки после последнего абзаца. + public void IntoEndOfText() + { + var sections = _body.Descendants().ToList(); + var last = sections.LastOrDefault(s => s.Parent == _body); + var width = TextWidth(last ?? sections.LastOrDefault(), withColumns: true); + foreach (var element in Block(_main, width, spaceBefore: 240)) + { + if (last is null) + _body.Append(element); + else + _body.InsertBefore(element, last); + } + } + + /// + /// «Все»: в каждый нижний колонтитул, который виден на страницах (обычный, + /// первой страницы раздела, чётных), — при его отсутствии создаётся новый. + /// + public void IntoAllFooters() + { + var sections = Sections(); + var width = sections.Min(s => TextWidth(s, withColumns: false)); + var evenAndOdd = _main.DocumentSettingsPart?.Settings?.GetFirstChild() is { } even + && (even.Val is null || even.Val.Value); + var stamped = new HashSet(); + for (var i = 0; i < sections.Count; i++) + { + var types = new List { HeaderFooterValues.Default }; + if (HasTitlePage(sections[i])) + types.Add(HeaderFooterValues.First); + if (evenAndOdd) + types.Add(HeaderFooterValues.Even); + + foreach (var type in types) + { + var id = Effective(sections, i, type); + if (id is null) + { + id = NewFooter(null); + SetReference(sections[i], new FooterReference { Type = type, Id = id }); + } + + if (stamped.Add(id)) + AppendToFooter((FooterPart)_main.GetPartById(id), width); + } + } + } + + /// + /// «Первая»: отдельный колонтитул первой страницы (копия прежнего + плашки). + /// Если у раздела не было «особого колонтитула первой страницы», он + /// включается, а верхний колонтитул копируется, чтобы первая страница + /// выглядела как раньше. Колонтитулы других разделов не меняются. + /// + public void IntoFirstPageFooter() + { + var sections = Sections(); + var first = sections[0]; + var hadTitlePage = HasTitlePage(first); + var laterTitlePage = sections.Skip(1).Any(HasTitlePage); + var shownType = hadTitlePage ? HeaderFooterValues.First : HeaderFooterValues.Default; + + // Нижний: копия того, что было на первой странице, + плашки. + var shownFooter = Effective(sections, 0, shownType); + var footerId = NewFooter(shownFooter); + PinForLaterSections(sections, laterTitlePage); + SetReference(first, new FooterReference { Type = HeaderFooterValues.First, Id = footerId }); + AppendToFooter((FooterPart)_main.GetPartById(footerId), TextWidth(first, withColumns: false)); + + if (hadTitlePage) + return; + + // Верхний: первая страница показывала обычный — копируем его в «первую страницу». + var defaultHeader = Effective(sections, 0, HeaderFooterValues.Default); + var staleFirstHeader = Own(first, HeaderFooterValues.First); + if (defaultHeader is not null || staleFirstHeader is not null) + { + var headerId = NewHeader(defaultHeader); + PinForLaterSections(sections, laterTitlePage); + SetReference(first, new HeaderReference { Type = HeaderFooterValues.First, Id = headerId }); + } + + SetTitlePage(first); + } + + // Разделы без своей ссылки наследуют колонтитул предыдущего раздела. Перед + // заменой колонтитула первой страницы в первом разделе второй раздел + // закрепляет прежний — иначе плашка появилась бы и на его первой странице. + private void PinForLaterSections(List sections, bool laterTitlePage) + where T : HeaderFooterReferenceType, new() + { + if (sections.Count < 2 || !laterTitlePage || Own(sections[1], HeaderFooterValues.First) is not null) + return; + var previous = Own(sections[0], HeaderFooterValues.First) + ?? (typeof(T) == typeof(FooterReference) ? NewFooter(null) : NewHeader(null)); + SetReference(sections[1], new T { Type = HeaderFooterValues.First, Id = previous }); + } + + private List Sections() + { + var sections = _body.Descendants().ToList(); + if (sections.Count == 0 || sections[^1].Parent != _body) + { + var last = new SectionProperties(); + _body.Append(last); + sections.Add(last); + } + + return sections; + } + + private static bool HasTitlePage(SectionProperties section) => + section.GetFirstChild() is { } titlePage && (titlePage.Val is null || titlePage.Val.Value); + + private static void SetTitlePage(SectionProperties section) + { + section.RemoveAllChildren(); + // По схеме titlePg стоит перед textDirection, bidi, rtlGutter, docGrid… + var next = section.ChildElements.FirstOrDefault(e => e is TextDirection or BiDi or GutterOnRight + or DocGrid or PrinterSettingsReference or SectionPropertiesChange); + if (next is null) + section.Append(new TitlePage()); + else + section.InsertBefore(new TitlePage(), next); + } + + private string? Own(SectionProperties section, HeaderFooterValues type) where T : HeaderFooterReferenceType => + section.Elements() + .Where(r => (r.Type?.Value ?? HeaderFooterValues.Default) == type) + .Select(r => r.Id?.Value) + .FirstOrDefault(id => id is not null && IsPart(id)); + + private string? Effective(List sections, int index, HeaderFooterValues type) + where T : HeaderFooterReferenceType + { + for (var i = index; i >= 0; i--) + { + if (Own(sections[i], type) is { } id) + return id; + } + + return null; + } + + private bool IsPart(string id) where T : HeaderFooterReferenceType => + _main.TryGetPartById(id, out var part) + && (typeof(T) == typeof(FooterReference) ? part is FooterPart : part is HeaderPart); + + private static void SetReference(SectionProperties section, HeaderFooterReferenceType reference) + { + var type = reference.Type?.Value ?? HeaderFooterValues.Default; + foreach (var old in section.ChildElements.OfType() + .Where(r => r.GetType() == reference.GetType() + && (r.Type?.Value ?? HeaderFooterValues.Default) == type) + .ToList()) + old.Remove(); + + // Ссылки на колонтитулы — первые элементы sectPr. + var lastReference = section.ChildElements.OfType().LastOrDefault(); + if (lastReference is null) + section.PrependChild(reference); + else + section.InsertAfter(reference, lastReference); + } + + private string NewFooter(string? copyOf) + { + var part = _main.AddNewPart(); + if (copyOf is null) + part.Footer = new Footer(); + else + CopyPart((FooterPart)_main.GetPartById(copyOf), part); + return _main.GetIdOfPart(part); + } + + private string NewHeader(string? copyOf) + { + var part = _main.AddNewPart(); + if (copyOf is null) + part.Header = new Header(new Paragraph()); + else + CopyPart((HeaderPart)_main.GetPartById(copyOf), part); + return _main.GetIdOfPart(part); + } + + // Копия колонтитула: содержимое и связи (картинки и прочие части — общие, + // внешние ссылки — те же). Номера рисунков и абзацев в копии новые. + private void CopyPart(OpenXmlPart source, OpenXmlPart target) + { + source.RootElement?.Save(); + using (var stream = source.GetStream(FileMode.Open, FileAccess.Read)) + target.FeedData(stream); + foreach (var child in source.Parts) + target.AddPart(child.OpenXmlPart, child.RelationshipId); + foreach (var link in source.HyperlinkRelationships) + target.AddHyperlinkRelationship(link.Uri, link.IsExternal, link.Id); + foreach (var external in source.ExternalRelationships) + target.AddExternalRelationship(external.RelationshipType, external.Uri, external.Id); + + var root = target.RootElement + ?? throw new InvalidOperationException("Не удалось прочитать колонтитул документа."); + foreach (var drawing in root.Descendants()) + drawing.Id = _nextDrawingId++; + foreach (var element in root.Descendants().Where(e => e is Paragraph or TableRow).ToList()) + { + element.RemoveAttribute("paraId", Word2010Namespace); + element.RemoveAttribute("textId", Word2010Namespace); + } + + foreach (var sdtId in root.Descendants().ToList()) + sdtId.Remove(); + } + + private void AppendToFooter(FooterPart part, int width) + { + var footer = part.Footer ??= new Footer(); + foreach (var element in Block(part, width, spaceBefore: 60)) + footer.Append(element); + } + + private uint MaxDrawingId() + { + var roots = new List { _main.Document }; + roots.AddRange(_main.HeaderParts.Select(p => (OpenXmlPartRootElement?)p.Header)); + roots.AddRange(_main.FooterParts.Select(p => (OpenXmlPartRootElement?)p.Footer)); + roots.Add(_main.FootnotesPart?.Footnotes); + roots.Add(_main.EndnotesPart?.Endnotes); + roots.Add(_main.WordprocessingCommentsPart?.Comments); + return roots.Where(r => r is not null) + .SelectMany(r => r!.Descendants()) + .Select(d => d.Id?.Value ?? 0) + .DefaultIfEmpty(0u) + .Max(); + } + + // Ширина текста раздела: страница без полей (и без переплёта); для колонок — колонка. + private static int TextWidth(SectionProperties? section, bool withColumns) + { + var size = section?.GetFirstChild(); + var margin = section?.GetFirstChild(); + var width = (int)(size?.Width?.Value ?? 11906) + - (int)(margin?.Left?.Value ?? 1701) + - (int)(margin?.Right?.Value ?? 850) + - (int)(margin?.Gutter?.Value ?? 0); + if (withColumns && section?.GetFirstChild() is { } columns + && columns.ColumnCount?.Value is > 1 and var count) + { + var widths = columns.Elements() + .Select(c => int.TryParse(c.Width?.Value, out var w) ? w : 0) + .Where(w => w > 0) + .ToList(); + var space = int.TryParse(columns.Space?.Value, out var s) ? s : 720; + width = columns.EqualWidth?.Value == false && widths.Count > 0 + ? widths.Min() + : (width - space * (count - 1)) / count; + } + + return Math.Max(width, 1440); + } + + /// + /// Плашки у правого края: таблица-раскладка без рамок, в строке столько + /// плашек, сколько входит по ширине; неполная строка прижата вправо. + /// + private IEnumerable Block(OpenXmlPart host, int available, int spaceBefore) + { + var columns = Math.Clamp((available + StampGap) / (MinStampWidth + StampGap), 1, _signers.Count); + var stampWidth = Math.Min(MaxStampWidth, (available - StampGap * (columns - 1)) / columns); + var totalWidth = stampWidth * columns + StampGap * (columns - 1); + + var grid = new TableGrid(); + for (var c = 0; c < columns; c++) + { + if (c > 0) + grid.Append(new GridColumn { Width = StampGap.ToString() }); + grid.Append(new GridColumn { Width = stampWidth.ToString() }); + } + + var layout = new Table( + new TableProperties( + new TableWidth { Width = totalWidth.ToString(), Type = TableWidthUnitValues.Dxa }, + new TableJustification { Val = TableRowAlignmentValues.Right }, + Borders(BorderValues.None), + new TableLayout { Type = TableLayoutValues.Fixed }, + // Зазор между строками плашек — нижним полем ячейки: абзац после + // вложенной таблицы программы просмотра могут не показывать. + new TableCellMarginDefault( + new TopMargin { Width = "0", Type = TableWidthUnitValues.Dxa }, + new TableCellLeftMargin { Width = 0, Type = TableWidthValues.Dxa }, + new BottomMargin { Width = StampGap.ToString(), Type = TableWidthUnitValues.Dxa }, + new TableCellRightMargin { Width = 0, Type = TableWidthValues.Dxa })), + grid); + + for (var start = 0; start < _signers.Count; start += columns) + { + var signers = _signers.Skip(start).Take(columns).ToList(); + var empty = columns - signers.Count; + var row = new TableRow(new TableRowProperties(new CantSplit())); + for (var c = 0; c < columns; c++) + { + if (c > 0) + row.Append(Cell(StampGap, Spacer(0, 20))); + row.Append(c < empty + ? Cell(stampWidth, Spacer(0, 20)) + : Cell(stampWidth, StampTable(signers[c - empty], stampWidth, host), Spacer(0, 20))); + } + + layout.Append(row); + } + + // Абзац до таблицы не даёт ей слиться с таблицей документа; после — обязателен. + return new OpenXmlElement[] { Spacer(spaceBefore, 20, keepNext: true), layout, Spacer(0, 20) }; + } + + private Table StampTable(PdfStamper.StampSigner signer, int width, OpenXmlPart host) + { + var logoId = LogoRelationship(host); + var logoColumn = LogoTwips + CellPadding * 2; + var grid = logoId is null + ? new TableGrid(new GridColumn { Width = width.ToString() }) + : new TableGrid( + new GridColumn { Width = logoColumn.ToString() }, + new GridColumn { Width = (width - logoColumn).ToString() }); + + var table = new Table( + new TableProperties( + new TableWidth { Width = width.ToString(), Type = TableWidthUnitValues.Dxa }, + Borders(BorderValues.Single), + new TableLayout { Type = TableLayoutValues.Fixed }, + new TableCellMarginDefault( + new TopMargin { Width = "57", Type = TableWidthUnitValues.Dxa }, + new TableCellLeftMargin { Width = CellPadding, Type = TableWidthValues.Dxa }, + new BottomMargin { Width = "57", Type = TableWidthUnitValues.Dxa }, + new TableCellRightMargin { Width = CellPadding, Type = TableWidthValues.Dxa })), + grid); + + var title = new List + { + Text(PdfStamper.TitleLine1, bold: true, TitleSize), + Text(PdfStamper.TitleLine2, bold: true, TitleSize), + }; + var heading = new TableRow(new TableRowProperties(new CantSplit())); + if (logoId is not null) + { + heading.Append(Cell(logoColumn, new Paragraph(Layout(0, null), new Run(LogoDrawing(logoId))))); + heading.Append(Cell(width - logoColumn, title.ToArray())); + } + else + { + heading.Append(Cell(width, title.ToArray())); + } + + var lines = PdfStamper.StampLines(signer, _options); + var body = lines.Select((line, i) => (OpenXmlElement)Text(line, bold: false, TextSize, i == 0 ? 60 : 0)) + .ToArray(); + var details = new TableRow(new TableRowProperties(new CantSplit()), + Cell(width, logoId is null ? 1 : 2, body)); + + table.Append(heading, details); + return table; + } + + private static TableBorders Borders(BorderValues outer) + { + T Border(BorderValues value) where T : BorderType, new() => value == BorderValues.Single + ? new T { Val = value, Size = BorderSize, Space = 0, Color = Blue } + : new T { Val = value, Size = 0, Space = 0, Color = "auto" }; + + return new TableBorders( + Border(outer), + Border(outer), + Border(outer), + Border(outer), + Border(BorderValues.None), + Border(BorderValues.None)); + } + + private static TableCell Cell(int width, params OpenXmlElement[] content) => Cell(width, 1, content); + + private static TableCell Cell(int width, int span, params OpenXmlElement[] content) + { + var properties = new TableCellProperties(new TableCellWidth { Width = width.ToString(), Type = TableWidthUnitValues.Dxa }); + if (span > 1) + properties.Append(new GridSpan { Val = span }); + properties.Append(new TableCellVerticalAlignment { Val = TableVerticalAlignmentValues.Center }); + var cell = new TableCell(properties); + cell.Append(content); + return cell; + } + + // Явные отступы и интервалы: стиль «Обычный» документа (красная строка, + // полуторный интервал, выравнивание по ширине) не должен менять плашку. + private static ParagraphProperties Layout(int spaceBefore, int? exactLine, bool keepNext = false) + { + var properties = new ParagraphProperties(); + if (keepNext) + properties.Append(new KeepNext()); + properties.Append( + new SpacingBetweenLines + { + Before = spaceBefore.ToString(), + BeforeAutoSpacing = false, + After = "0", + AfterAutoSpacing = false, + Line = (exactLine ?? 240).ToString(), + LineRule = exactLine is null ? LineSpacingRuleValues.Auto : LineSpacingRuleValues.Exact, + }, + new Indentation { Left = "0", Right = "0", FirstLine = "0" }, + new Justification { Val = JustificationValues.Left }); + return properties; + } + + private static Paragraph Text(string text, bool bold, string size, int spaceBefore = 0) + { + var run = new RunProperties(new RunFonts { Ascii = FontName, HighAnsi = FontName, EastAsia = FontName, ComplexScript = FontName }); + if (bold) + run.Append(new Bold(), new BoldComplexScript()); + run.Append(new NoProof(), new Color { Val = Blue }, new FontSize { Val = size }, new FontSizeComplexScript { Val = size }); + return new Paragraph(Layout(spaceBefore, null), + new Run(run, new Text(text) { Space = SpaceProcessingModeValues.Preserve })); + } + + // Пустой абзац заданной высоты (exact): отступ перед плашками и обязательный + // абзац после таблицы (в документе и в ячейке после вложенной таблицы). + private static Paragraph Spacer(int spaceBefore, int height, bool keepNext = false) + { + var properties = Layout(spaceBefore, height, keepNext); + properties.Append(new ParagraphMarkRunProperties(new FontSize { Val = "2" }, new FontSizeComplexScript { Val = "2" })); + return new Paragraph(properties); + } + + private string? LogoRelationship(OpenXmlPart host) + { + if (_logo is null) + return null; + if (_logoIds.TryGetValue(host, out var id)) + return id; + + var image = host switch + { + MainDocumentPart main => main.AddImagePart(ImagePartType.Png), + FooterPart footer => footer.AddImagePart(ImagePartType.Png), + _ => throw new InvalidOperationException("Неожиданная часть документа для логотипа."), + }; + using (var stream = new MemoryStream(_logo.Png)) + image.FeedData(stream); + id = host.GetIdOfPart(image); + _logoIds[host] = id; + return id; + } + + private Drawing LogoDrawing(string relationshipId) + { + var logo = _logo!; + var scale = (double)LogoTwips * EmuPerTwip / Math.Max(logo.Width, logo.Height); + var cx = (long)Math.Round(logo.Width * scale); + var cy = (long)Math.Round(logo.Height * scale); + var id = _nextDrawingId++; + const string name = "Логотип"; + + return new Drawing(new DW.Inline( + new DW.Extent { Cx = cx, Cy = cy }, + new DW.EffectExtent { LeftEdge = 0L, TopEdge = 0L, RightEdge = 0L, BottomEdge = 0L }, + new DW.DocProperties { Id = id, Name = name }, + new DW.NonVisualGraphicFrameDrawingProperties(new A.GraphicFrameLocks { NoChangeAspect = true }), + new A.Graphic(new A.GraphicData( + new PIC.Picture( + new PIC.NonVisualPictureProperties( + new PIC.NonVisualDrawingProperties { Id = 0U, Name = name }, + new PIC.NonVisualPictureDrawingProperties()), + new PIC.BlipFill(new A.Blip { Embed = relationshipId }, new A.Stretch(new A.FillRectangle())), + new PIC.ShapeProperties( + new A.Transform2D(new A.Offset { X = 0L, Y = 0L }, new A.Extents { Cx = cx, Cy = cy }), + new A.PresetGeometry(new A.AdjustValueList()) { Preset = A.ShapeTypeValues.Rectangle }))) + { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })) + { + DistanceFromTop = 0U, + DistanceFromBottom = 0U, + DistanceFromLeft = 0U, + DistanceFromRight = 0U, + }); + } + + // Логотип — в PNG (прозрачность сохраняется), крупные картинки уменьшаются: + // на плашке он 34 pt. Не читается — штамп без логотипа, как на PDF. + private static Logo? LoadLogo(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return null; + try + { + using var file = File.OpenRead(path); + using var decoded = SKBitmap.Decode(file); + if (decoded is null || decoded.Width <= 0 || decoded.Height <= 0) + return null; + + var scale = Math.Min(1.0, (double)MaxLogoPixels / Math.Max(decoded.Width, decoded.Height)); + using var resized = scale < 1 + ? decoded.Resize(new SKImageInfo( + Math.Max(1, (int)Math.Round(decoded.Width * scale)), + Math.Max(1, (int)Math.Round(decoded.Height * scale)), + SKColorType.Rgba8888, SKAlphaType.Premul), + SKFilterQuality.High) + : null; + var bitmap = resized ?? decoded; + using var image = SKImage.FromBitmap(bitmap); + using var png = image.Encode(SKEncodedImageFormat.Png, 100); + return png is null ? null : new Logo(png.ToArray(), bitmap.Width, bitmap.Height); + } + catch (Exception) + { + return null; + } + } + } +} diff --git a/src/SignService/SignService.csproj b/src/SignService/SignService.csproj index 18c13e7..1553bc3 100644 --- a/src/SignService/SignService.csproj +++ b/src/SignService/SignService.csproj @@ -10,7 +10,7 @@ true SignService — подписание документов ЭЦП SignService - 1.3.3 + 1.4.0 Зайнуллин Марат Илгамович Зайнуллин Марат Илгамович © 2026 Зайнуллин Марат Илгамович, +7-963-694-2461, zaynullinmi@gmail.com @@ -24,8 +24,9 @@ + - + diff --git a/src/SignService/ViewModels/MainWindowViewModel.cs b/src/SignService/ViewModels/MainWindowViewModel.cs index 55105c8..732e577 100644 --- a/src/SignService/ViewModels/MainWindowViewModel.cs +++ b/src/SignService/ViewModels/MainWindowViewModel.cs @@ -188,6 +188,8 @@ private async Task CheckUpdatesOnStartAsync() [NotifyCanExecuteChangedFor(nameof(BuildContainerItemCommand))] [NotifyCanExecuteChangedFor(nameof(SplitItemCommand))] [NotifyCanExecuteChangedFor(nameof(RemoveSignerItemCommand))] + [NotifyCanExecuteChangedFor(nameof(RemoveFileCommand))] + [NotifyCanExecuteChangedFor(nameof(AddPoaCommand))] private bool _isBusy; [ObservableProperty] @@ -393,7 +395,7 @@ partial void OnUseStampChanged(bool value) } // В режиме «подписывать копию со штампом» найденные подписи оригинала к подписи не относятся. - private bool SignsStampedCopy(SignFileItem file) => UseStamp && StampSignCopy && PdfStamper.IsPdf(file.FilePath); + private bool SignsStampedCopy(SignFileItem file) => UseStamp && StampSignCopy && DocumentStamper.IsSupported(file.FilePath); private void UpdateStampedCopyMode() { @@ -816,6 +818,15 @@ public async Task AddFilesAsync(params string[] filePaths) if (added.Count > 0) StatusText = $"Добавлено файлов: {added.Count}. Всего в очереди: {Files.Count}"; + // Подписи к файлам, которые уже подписываются, не прикладываем: в текущий + // пакет они не попадут, а строка показывала бы их как приложенные. + if (IsBusy && signatures.Count > 0) + { + Log("Подписи не приложены — идёт подписание; перетащите их после окончания: " + + string.Join(", ", signatures.Select(path => "«" + System.IO.Path.GetFileName(path) + "»"))); + signatures.Clear(); + } + // Новые документы ищут подписи в своей папке; перетащенные подписи сверяются со всей // очередью. Во время подписания план уже стоявших файлов не меняется. var targets = signatures.Count > 0 ? Files.ToList() : added; @@ -986,7 +997,8 @@ public async Task BuildContainerAsync(string documentPath, IReadOnlyList } } - private bool CanStampOnly() => !IsBusy && SelectedCertificate is not null; + // Сертификат не обязателен: плашки ставятся и по уже имеющимся подписям документа. + private bool CanStampOnly() => !IsBusy; [RelayCommand(CanExecute = nameof(CanStampOnly))] private void StampOnly() => StampOnlyRequested?.Invoke(this, EventArgs.Empty); @@ -1014,52 +1026,57 @@ public void SaveStampOptions(Views.StampOptionsDialog.Result result) } /// - /// Ставит визуальный штамп на PDF без подписания. Если рядом с документом - /// есть «имя.sig», плашки ставятся для ВСЕХ его подписантов; иначе — - /// по данным выбранного сертификата. + /// Ставит визуальный штамп на PDF или документ Word без подписания. Плашки — по + /// подписям документа, которые проверяются по нему: указанной (ПКМ по файлу), + /// «имя.sig» или найденным в папке по содержимому. Если подписей нет — по + /// выбранному сертификату (документ ещё не подписан). /// - public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfStamper.StampOptions options, + public async Task StampWithoutSigningAsync(IReadOnlyList paths, PdfStamper.StampOptions options, string? signatureHint = null) { - if (pdfPaths.Count == 0 || SelectedCertificate is not { } item) + if (paths.Count == 0) return; - options = options with { PoaNumber = options.PoaNumber ?? Poa?.Number }; + var current = SelectedCertificate?.Certificate; + var poaNumber = options.PoaNumber ?? Poa?.Number; IsBusy = true; try { - foreach (var path in pdfPaths) + foreach (var path in paths) { var name = System.IO.Path.GetFileName(path); try { - if (!PdfStamper.IsPdf(path)) + var note = DocumentStamper.Note(path, options); + if (!DocumentStamper.IsSupported(path)) { - Log($"«{name}»: пропущен (не PDF)"); + Log($"«{name}»: пропущен — {note}"); continue; } - var stamped = await Task.Run(() => + var (stamped, skipped) = await Task.Run(() => { - var sigPath = signatureHint ?? path + ".sig"; - var certs = System.IO.File.Exists(sigPath) - ? CmsMerger.GetSignerCertificates(System.IO.File.ReadAllBytes(sigPath)) - : Array.Empty(); - try - { - return PdfStamper.CreateStampedCopy( - path, - certs.Count > 0 ? certs : new[] { item.Certificate }, - options, - DateTime.Now); - } - finally - { - foreach (var c in certs) - c.Dispose(); - } + var sources = StampSignatureSources(path, signatureHint); + var document = System.IO.File.ReadAllBytes(path); + using var found = DocumentStamper.SignersFromSignatures( + sources.Select(System.IO.File.ReadAllBytes), document); + var signers = found.Signers + .Select(s => current is not null && DocumentStamper.SameCertificate(s.Certificate, current) + ? s with { PoaNumber = poaNumber } + : s) + .ToList(); + if (signers.Count == 0 && sources.Count == 0 && current is not null) + signers.Add(new PdfStamper.StampSigner(current, DateTime.Now, poaNumber)); + if (signers.Count == 0) + throw new InvalidOperationException(sources.Count == 0 + ? "у документа нет подписи, а сертификат не выбран" + : "подписи («" + string.Join("», «", sources.Select(System.IO.Path.GetFileName)) + + "») не соответствуют этому документу — штамп «Документ подписан» не поставлен"); + return (DocumentStamper.CreateStampedCopy(path, signers, options), found.Skipped); }); - Log($"Штамп без подписания: «{name}» → «{System.IO.Path.GetFileName(stamped)}»"); + Log($"Штамп без подписания: «{name}» → «{System.IO.Path.GetFileName(stamped)}»" + + (note is null ? "" : $" ({note})") + + (skipped.Count == 0 ? "" : "; без плашки (подпись не соответствует документу): " + string.Join("; ", skipped))); } catch (Exception ex) { @@ -1073,6 +1090,20 @@ public async Task StampWithoutSigningAsync(IReadOnlyList pdfPaths, PdfSt } } + // Указанная подпись; иначе «имя.sig»; иначе подписи из папки, относящиеся к документу по содержимому. + private static IReadOnlyList StampSignatureSources(string path, string? hint) + { + if (hint is not null) + return System.IO.File.Exists(hint) ? new[] { hint } : Array.Empty(); + if (System.IO.File.Exists(path + ".sig")) + return new[] { path + ".sig" }; + return SignatureDiscovery.Discover(new[] { path }, Array.Empty()) + .SelectMany(d => d.FolderMatches) + .Where(match => match.MatchingSigners > 0) + .Select(match => match.Path) + .ToList(); + } + /// ПКМ: подписанты и проверка ЭЦП для файла из списка (.sig рядом). [RelayCommand] private async Task InspectSignaturesAsync(SignFileItem item) @@ -1299,7 +1330,8 @@ public async Task ExtractContainersAsync(IReadOnlyList containerPaths) } } - [RelayCommand] + // Во время подписания очередь не меняется: убранный файл всё равно был бы подписан. + [RelayCommand(CanExecute = nameof(CanBrowse))] private void RemoveFile(SignFileItem item) { Files.Remove(item); @@ -1505,6 +1537,8 @@ private async Task SignAllAsync() foreach (var file in pending) { + if (!Files.Contains(file)) + continue; file.Warning = null; file.Status = SignStatus.Signing; StatusText = $"Подписание: {file.FileName}"; @@ -1522,8 +1556,9 @@ private async Task SignAllAsync() var options = new DocumentSigner.SignOptions { Detached = IsDetached, - // Копия со штампом объединяется только со своей прежней подписью, как раньше. - MergeWithExisting = existing is not null || (file.SignsStampedCopy && MergeWithExisting), + // Копия со штампом всегда получает новый файл подписи: подписанную + // копию DocumentSigner не перезаписывает. + MergeWithExisting = existing is not null, ExistingSignaturePath = existing, ExtraSignatures = extras, Timestamp = UseTimestamp, @@ -1553,13 +1588,18 @@ private async Task SignAllAsync() file.Status = SignStatus.Signed; signed++; + var stampNote = stampParameters is null ? null : DocumentStamper.Note(file.FilePath, stampParameters); + if (result.StampWarning is { } stampWarning) + file.Warning = file.Warning is null ? "Штамп: " + stampWarning : file.Warning + " Штамп: " + stampWarning; Log($" [OK] {file.FileName} → {file.StatusDisplay}" + (result.BackupPath is null ? "" : $". Резервная копия: {System.IO.Path.GetFileName(result.BackupPath)}") + (result.StampedCopyPath is null ? "" - : $"; копия со штампом (без подписи): {System.IO.Path.GetFileName(result.StampedCopyPath)}")); + : $"; копия со штампом (без подписи): {System.IO.Path.GetFileName(result.StampedCopyPath)}") + + (stampNote is null ? "" : $"; {stampNote}") + + (result.StampWarning is null ? "" : $"; ВНИМАНИЕ: {result.StampWarning}")); } catch (Exception ex) { diff --git a/src/SignService/ViewModels/SignFileItem.cs b/src/SignService/ViewModels/SignFileItem.cs index 6f4d1bf..971ac00 100644 --- a/src/SignService/ViewModels/SignFileItem.cs +++ b/src/SignService/ViewModels/SignFileItem.cs @@ -123,8 +123,8 @@ public SignFileItem(string filePath) private bool _isDiscovering; /// - /// Подписывается копия со штампом (режим «подписывать копию»): найденные подписи - /// оригинала к ней не относятся — объединяются только приложенные. + /// Подписывается копия со штампом (режим «подписывать копию»): это другой файл, + /// поэтому ни найденные, ни приложенные подписи оригинала к ней не относятся. /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(CanChooseCoSign))] @@ -268,6 +268,8 @@ public void ApplyPreferredMode(bool addToExisting, bool resetPin) public IReadOnlyList PlannedMergeInputs() { var list = new List(); + if (SignsStampedCopy) + return list; if (!SignsStampedCopy && SelectedCoSignOption is { CreateNew: false, SignaturePath: { } selected }) { list.Add(selected); @@ -400,7 +402,10 @@ private string BuildPlan() var outside = _extraSignatures.Count(extra => !planned.Any(path => SignatureDiscovery.SamePath(path, extra))); var extras = outside > 0 ? $"; приложенные подписи ({outside}) будут объединены с вашей" : ""; if (SignsStampedCopy) - return "будет подписана копия со штампом — для неё создаётся новый файл подписи" + extras; + return "будет подписана копия со штампом — для неё создаётся новый файл подписи" + + (_extraSignatures.Count > 0 + ? $"; приложенные подписи ({_extraSignatures.Count}) относятся к оригиналу и в подпись копии не войдут" + : ""); if (SelectedCoSignOption is { CreateNew: false, SignaturePath: { } path }) { var others = _discovered.Where(match => !SignatureDiscovery.SamePath(match.Path, path)).ToList(); diff --git a/src/SignService/Views/MainWindow.axaml b/src/SignService/Views/MainWindow.axaml index 5f836b5..5a4f42f 100644 --- a/src/SignService/Views/MainWindow.axaml +++ b/src/SignService/Views/MainWindow.axaml @@ -11,8 +11,9 @@ - - + + - + + Content="Штамп на документ (PDF, Word)" + ToolTip.Tip="Перед подписанием откроется диалог параметров штампа (страницы, дата, лого). Штамп ставится на PDF и документы Word (.docx, .docm). По умолчанию подписывается ОРИГИНАЛ, а копия «имя (со штампом).pdf» или «имя (со штампом).docx» — отдельный файл без подписи с плашками всех подписантов (безопасно при соподписании)."/> - +