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» — отдельный файл без подписи с плашками всех подписантов (безопасно при соподписании)."/>
-
+
+ ToolTip.Tip="Как в Контуре: выберите файл МЧД (XML) и подпись руководителя (.sig). Программа проверит подпись, срок действия и соответствие представителя вашему сертификату. Файлы МЧД будут скопированы рядом с подписанным документом, номер МЧД попадёт в штамп на документе."/>
-
-
diff --git a/src/SignService/Views/MainWindow.axaml.cs b/src/SignService/Views/MainWindow.axaml.cs
index a52f95f..1f714ed 100644
--- a/src/SignService/Views/MainWindow.axaml.cs
+++ b/src/SignService/Views/MainWindow.axaml.cs
@@ -113,7 +113,9 @@ private static IEnumerable ExpandToFilePaths(IEnumerable i
if (item is IStorageFolder && Directory.Exists(path))
{
- foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories))
+ // Недоступная подпапка (нет прав, системная) пропускается, а не обрывает всё перетаскивание.
+ var options = new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true };
+ foreach (var file in Directory.EnumerateFiles(path, "*", options))
yield return file;
}
else if (File.Exists(path))
@@ -184,11 +186,16 @@ private async System.Threading.Tasks.Task BrowseStampOnlyAsync(MainWindowViewMod
{
var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
- Title = "PDF-документы для штампа (без подписания)",
+ Title = "Документы PDF и Word для штампа (без подписания)",
AllowMultiple = true,
FileTypeFilter = new[]
{
+ new FilePickerFileType("Документы PDF и Word (*.pdf, *.docx, *.docm)")
+ {
+ Patterns = new[] { "*.pdf", "*.docx", "*.docm" },
+ },
new FilePickerFileType("Документы PDF (*.pdf)") { Patterns = new[] { "*.pdf" } },
+ new FilePickerFileType("Документы Word (*.docx, *.docm)") { Patterns = new[] { "*.docx", "*.docm" } },
},
});
diff --git a/src/SignService/Views/StampOptionsDialog.axaml b/src/SignService/Views/StampOptionsDialog.axaml
index d0fa4bc..d9f3e15 100644
--- a/src/SignService/Views/StampOptionsDialog.axaml
+++ b/src/SignService/Views/StampOptionsDialog.axaml
@@ -18,6 +18,10 @@
IsEnabled="{Binding #PagesCustom.IsChecked}"/>
+
+
diff --git a/src/SignService/Views/StampOptionsDialog.axaml.cs b/src/SignService/Views/StampOptionsDialog.axaml.cs
index 38e1c87..d8e70f6 100644
--- a/src/SignService/Views/StampOptionsDialog.axaml.cs
+++ b/src/SignService/Views/StampOptionsDialog.axaml.cs
@@ -10,7 +10,7 @@ namespace SignService.Views;
///
/// Диалог параметров визуального штампа: страницы (последняя/первая/все/указанные),
/// дата, логотип и режим «подписывать копию со штампом». Показывается перед
-/// подписанием со штампом и перед операцией «Штамп на PDF».
+/// подписанием со штампом и перед операцией «Штамп на PDF/Word».
/// Возвращает результат либо null при отмене.
///
public partial class StampOptionsDialog : Window
diff --git a/tests/SignService.Tests/Program.cs b/tests/SignService.Tests/Program.cs
index 38542be..6f22ce3 100644
--- a/tests/SignService.Tests/Program.cs
+++ b/tests/SignService.Tests/Program.cs
@@ -1,6 +1,6 @@
// Интеграционные тесты SignService: Стрибог, подпись CAdES-BES, соподписание,
// нормализация BER, извлечение из контейнера, штамп времени (CAdES-T, локальный
-// TSA-ответчик на openssl), визуальный штамп на PDF и объединение без подписания.
+// TSA-ответчик на openssl), визуальный штамп на PDF и Word и объединение без подписания.
// Запуск: dotnet run --project tests/SignService.Tests -c Release
// Криптография — managed-путь (RSA); нативный ГОСТ-путь (CryptSignMessage)
// проверяется вручную на Windows с КриптоПро.
@@ -363,6 +363,20 @@ int Convert(ReadOnlySpan s, MemoryStream o)
// при соподписании штамп отражает всех подписантов и не ломает подписи
var origBytes = await File.ReadAllBytesAsync(pdfPath);
File.Delete(pdfPath + ".sig");
+// подписанная копия со штампом не перезаписывается: её подпись сломалась бы
+try
+{
+ await signer.SignFileAsync(pdfPath, cert, new DocumentSigner.SignOptions { Detached = true, Stamp = true });
+ throw new Exception("a signed stamped copy must not be overwritten");
+}
+catch (InvalidOperationException e) when (e.Message.Contains("уже подписана"))
+{
+}
+if (File.Exists(pdfPath + ".sig")) throw new Exception("nothing must be signed when the copy is protected");
+cmsStamp.Decode(await File.ReadAllBytesAsync(stampedPath + ".sig"));
+cmsStamp.CheckSignature(verifySignatureOnly: true); // копия и её подпись целы
+Console.WriteLine("PDF stamp: signed stamped copy is protected from re-stamping: OK");
+File.Delete(stampedPath + ".sig");
File.Delete(stampedPath);
var sep1 = await signer.SignFileAsync(pdfPath, cert, new DocumentSigner.SignOptions
{
@@ -418,12 +432,18 @@ int Convert(ReadOnlySpan s, MemoryStream o)
await File.WriteAllBytesAsync(fakePdf, new byte[] { 1, 2, 3 });
try
{
- await signer.SignFileAsync(fakePdf, cert, new DocumentSigner.SignOptions { Stamp = true });
- throw new Exception("broken PDF must fail");
+ await signer.SignFileAsync(fakePdf, cert, new DocumentSigner.SignOptions { Stamp = true, StampSignCopy = true });
+ throw new Exception("broken PDF must fail in sign-copy mode");
}
catch (InvalidOperationException e) when (e.Message.Contains("штамп"))
{
}
+if (File.Exists(fakePdf + ".sig")) throw new Exception("sign-copy mode: nothing is signed when the stamp fails");
+// копия отдельно: подпись уже записана — сбой копии не превращает подписание в ошибку
+var brokenCopy = await signer.SignFileAsync(fakePdf, cert, new DocumentSigner.SignOptions { Stamp = true });
+if (!File.Exists(brokenCopy.SignaturePath) || brokenCopy.StampedCopyPath is not null
+ || brokenCopy.StampWarning?.Contains("не удалось") != true)
+ throw new Exception("separate mode: a failed stamped copy must be a warning: " + brokenCopy.StampWarning);
Console.WriteLine("PDF stamp variants (no date/logo, non-PDF, broken PDF): OK");
// ===== 12. Объединение подписей без подписания =====
@@ -807,6 +827,7 @@ await File.WriteAllTextAsync(expiredXmlPath,
await VerificationTests.RunAsync(tempRoot);
await ReviewRegressionTests.RunAsync(tempRoot, signer);
await CoSignTests.RunAsync(tempRoot, signer);
+await WordStampTests.RunAsync(tempRoot, signer);
UiSmokeTests.Run(tempRoot);
try { Directory.Delete(tempRoot, true); } catch { }
Console.WriteLine("ALL TESTS PASSED");
diff --git a/tests/SignService.Tests/UiSmokeTests.cs b/tests/SignService.Tests/UiSmokeTests.cs
index fd356cb..faa161c 100644
--- a/tests/SignService.Tests/UiSmokeTests.cs
+++ b/tests/SignService.Tests/UiSmokeTests.cs
@@ -172,11 +172,25 @@ private static void CheckCoSignRow(string tempRoot)
vm.IsBusy = true;
Dispatcher.UIThread.RunJobs();
Assert(!combo.IsEnabled, "co-sign choice must be locked while signing");
+ // параметры пакета и очередь не меняются посреди подписания (обход подтверждения)
+ var detached = window.GetVisualDescendants().OfType()
+ .First(r => r.Content?.ToString()?.StartsWith("Откреплённая") == true);
+ var applyToAll = window.GetVisualDescendants().OfType().First(c => c.Content?.ToString() == "Применить ко всем");
+ Assert(!detached.IsEffectivelyEnabled && !applyToAll.IsEffectivelyEnabled,
+ "signing options must be locked while signing");
+ Assert(!vm.RemoveFileCommand.CanExecute(vm.Files.First()) && !vm.AddPoaCommand.CanExecute(null),
+ "the queue and МЧД must be locked while signing");
vm.IsBusy = false;
+ Dispatcher.UIThread.RunJobs();
+ Assert(detached.IsEffectivelyEnabled && vm.RemoveFileCommand.CanExecute(vm.Files.First()) && vm.AddPoaCommand.CanExecute(null),
+ "options unlock after signing");
var frame = window.CaptureRenderedFrame() ?? throw new Exception("co-sign row must render");
- var shot = Path.Combine("/opt/cursor/artifacts", "main-window.png");
- Directory.CreateDirectory(Path.GetDirectoryName(shot)!);
- frame.Save(shot);
+ if (Environment.GetEnvironmentVariable("SIGNSERVICE_TEST_OUTPUT") is { Length: > 0 } output)
+ {
+ Directory.CreateDirectory(output);
+ frame.Save(Path.Combine(output, "main-window.png"));
+ }
+
frame.Dispose();
window.Close();
}
diff --git a/tests/SignService.Tests/WordStampTests.cs b/tests/SignService.Tests/WordStampTests.cs
new file mode 100644
index 0000000..931bf05
--- /dev/null
+++ b/tests/SignService.Tests/WordStampTests.cs
@@ -0,0 +1,629 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.Pkcs;
+using System.Security.Cryptography.X509Certificates;
+using DocumentFormat.OpenXml;
+using DocumentFormat.OpenXml.Packaging;
+using DocumentFormat.OpenXml.Validation;
+using DocumentFormat.OpenXml.Wordprocessing;
+using SignService.Services;
+using SignService.ViewModels;
+using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing;
+
+// Штамп в документе Word: копия «имя (со штампом).docx» проходит проверку схемы
+// OpenXML, исходник не меняется; «последняя»/«указанные» — плашки в конце текста,
+// «первая» — колонтитул первой страницы (другие разделы не затронуты), «все» —
+// все видимые нижние колонтитулы; подписание оригинала и копии со штампом.
+// SIGNSERVICE_TEST_OUTPUT=<каталог> — сохранить полученные документы для просмотра.
+internal static class WordStampTests
+{
+ private const string Title = "ДОКУМЕНТ ПОДПИСАН";
+
+ public static async Task RunAsync(string tempRoot, DocumentSigner signer)
+ {
+ var root = Path.Combine(tempRoot, "word-stamp");
+ Directory.CreateDirectory(root);
+ using var certA = MakeCert("CN=\"Иванов Иван Иванович <директор> & Ко\", O=ООО Ромашка");
+ using var certB = MakeCert("CN=Петрова Анна Сергеевна");
+ using var certC = MakeCert("CN=Сидоров Пётр");
+ var logo = WriteLogo(root);
+
+ CheckEndOfText(root, certA, certB, certC, logo);
+ CheckMacroEnabled(root, certA);
+ CheckFirstPage(root, certA, logo);
+ CheckFirstPageWithTitlePage(root, certA);
+ CheckAllFooters(root, certA, certB, logo);
+ CheckFormats(root, certA);
+ await CheckSigning(root, signer, certA, certB, logo);
+ await CheckSignCopyInViewModel(root, certA);
+ await CheckPlatesPerSigner(root, signer, certA, certB);
+ await CheckStampToolUsesValidSignaturesOnly(root, signer, certA, certB);
+ await CheckBrokenDocument(root, signer, certA);
+ Console.WriteLine("word stamp: end of text, first page, all footers, signing, schema validation: OK");
+ }
+
+ // Конец текста: документ заканчивается таблицей, у «Обычного» стиля красная
+ // строка и выравнивание по ширине; три подписанта — по две плашки в строке.
+ private static void CheckEndOfText(string root, X509Certificate2 certA, X509Certificate2 certB,
+ X509Certificate2 certC, string logo)
+ {
+ var path = Path.Combine(root, "договор.docx");
+ CreateDocument(path, (main, body) =>
+ {
+ body.Append(Para("Договор поставки"), Para("Текст договора."), DocumentTable());
+ body.Append(Section(main, footer: AddFooter(main, "Страница ", pageField: true)));
+ });
+ var original = File.ReadAllBytes(path);
+
+ var output = WordStamper.CreateStampedCopy(path, new[] { certA, certB, certC },
+ new PdfStamper.StampOptions { LogoPath = logo, PoaNumber = "МЧД-42" }, new DateTime(2026, 10, 19, 14, 5, 0));
+ Assert(output == Path.Combine(root, "договор (со штампом).docx"), "copy name: " + output);
+ Assert(File.ReadAllBytes(path).SequenceEqual(original), "the original must not change");
+ Validate(output);
+ Keep(output);
+
+ using (var doc = WordprocessingDocument.Open(output, false))
+ {
+ var main = doc.MainDocumentPart!;
+ var body = main.Document!.Body!;
+ var blocks = body.ChildElements.ToList();
+ var layout = blocks.OfType().Last();
+ var index = blocks.IndexOf(layout);
+ Assert(blocks[index - 1] is Paragraph && blocks[index - 2] is Table,
+ "a paragraph must separate the stamp from the document's own table");
+ Assert(blocks[index + 1] is Paragraph && blocks[index + 2] is SectionProperties,
+ "the stamp goes right before the final section properties");
+ Assert(Count(layout, Title) == 3, "one stamp per signer");
+ var text = layout.InnerText;
+ foreach (var expected in new[]
+ {
+ "Иванов Иван Иванович <директор> & Ко", "Петрова Анна Сергеевна", "Сидоров Пётр",
+ "Сертификат: " + certA.SerialNumber, "Действует на основании МЧД № МЧД-42",
+ "Дата подписания: 19.10.2026 14:05",
+ })
+ Assert(text.Contains(expected), "stamp text missing: " + expected);
+
+ var rows = layout.Elements().ToList();
+ Assert(rows.Count == 2 && rows.All(r => r.TableRowProperties?.GetFirstChild() is not null),
+ "two rows of two stamps on A4, rows are not split across pages");
+ Assert(!rows[1].Elements().First().Descendants().Any()
+ && rows[1].Elements().Last().Descendants().Any(),
+ "the incomplete last row is pressed to the right edge");
+ Assert(layout.Descendants().Count() == 3 && main.ImageParts.Count() == 1,
+ "the logo is stored once and shown on each stamp");
+ Assert(main.FooterParts.All(f => Count(f.Footer!, Title) == 0), "«last page» mode leaves footers alone");
+ var normalIndent = layout.Descendants().All(p => p.ParagraphProperties?.Indentation?.FirstLine?.Value == "0");
+ Assert(normalIndent, "stamp paragraphs override the first-line indent of the Normal style");
+ }
+
+ // повторный штамп пересоздаёт копию из оригинала, а не дописывает к старой
+ var again = WordStamper.CreateStampedCopy(path, new[] { certA }, new PdfStamper.StampOptions(), DateTime.Now);
+ using (var doc = WordprocessingDocument.Open(again, false))
+ Assert(Count(doc.MainDocumentPart!.Document!.Body!, Title) == 1, "re-stamping must start from the original");
+ Validate(again);
+ }
+
+ private static void CheckMacroEnabled(string root, X509Certificate2 cert)
+ {
+ var path = Path.Combine(root, "шаблон.docm");
+ CreateDocument(path, (main, body) => body.Append(Para("С макросами"), Section(main)), macro: true);
+ var output = WordStamper.CreateStampedCopy(path, new[] { cert }, new PdfStamper.StampOptions(), DateTime.Now);
+ Assert(output.EndsWith("шаблон (со штампом).docm", StringComparison.Ordinal), "docm keeps its extension: " + output);
+ using (var doc = WordprocessingDocument.Open(output, false))
+ Assert(doc.DocumentType == WordprocessingDocumentType.MacroEnabledDocument, "docm stays macro-enabled");
+ Validate(output);
+ }
+
+ // «Первая страница»: у первого раздела не было особого колонтитула первой
+ // страницы — он включается, шапка (с картинкой) и подвал копируются, плашка
+ // добавляется только туда. Второй раздел с особой первой страницей прежде
+ // показывал на ней пустые колонтитулы — так и остаётся.
+ private static void CheckFirstPage(string root, X509Certificate2 cert, string logo)
+ {
+ var path = Path.Combine(root, "два раздела.docx");
+ CreateDocument(path, (main, body) =>
+ {
+ var header = AddHeader(main, "Шапка бланка", File.ReadAllBytes(logo));
+ var footer = AddFooter(main, "Подвал бланка");
+ body.Append(Para("Раздел 1"), new Paragraph(new ParagraphProperties(Section(main, header, footer))));
+ body.Append(Para("Раздел 2"), Section(main, titlePage: true));
+ });
+
+ var output = WordStamper.CreateStampedCopy(path, new[] { cert },
+ new PdfStamper.StampOptions { Pages = PdfStamper.StampPages.First, LogoPath = logo }, DateTime.Now);
+ Validate(output);
+ Keep(output);
+
+ using var doc = WordprocessingDocument.Open(output, false);
+ var main = doc.MainDocumentPart!;
+ var sections = main.Document!.Body!.Descendants().ToList();
+ Assert(sections[0].GetFirstChild() is not null, "first section gets a distinct first page");
+
+ var firstFooter = (FooterPart)main.GetPartById(Ref(sections[0], HeaderFooterValues.First)!);
+ Assert(Count(firstFooter.Footer!, Title) == 1 && firstFooter.Footer!.InnerText.Contains("Подвал бланка"),
+ "first page footer = old footer + stamp");
+ var defaultFooter = (FooterPart)main.GetPartById(Ref(sections[0], HeaderFooterValues.Default)!);
+ Assert(Count(defaultFooter.Footer!, Title) == 0, "other pages keep the old footer");
+
+ var firstHeader = (HeaderPart)main.GetPartById(Ref(sections[0], HeaderFooterValues.First)!);
+ Assert(firstHeader.Header!.InnerText.Contains("Шапка бланка"), "first page keeps its header");
+ var blip = firstHeader.Header!.Descendants().Single().Embed!.Value!;
+ Assert(firstHeader.GetPartById(blip) is ImagePart, "the copied header keeps its picture");
+
+ var second = Ref(sections[1], HeaderFooterValues.First);
+ Assert(second is not null && Count(((FooterPart)main.GetPartById(second)).Footer!, Title) == 0
+ && Ref(sections[1], HeaderFooterValues.First) is not null,
+ "the second section's first page must not inherit the stamp");
+
+ var ids = DrawingIds(main);
+ Assert(ids.Count == ids.Distinct().Count(), "drawing ids stay unique: " + string.Join(",", ids));
+ }
+
+ // Особая первая страница уже была: её подвал общий со вторым разделом —
+ // плашка ставится в копию, второй раздел закрепляет прежний подвал.
+ private static void CheckFirstPageWithTitlePage(string root, X509Certificate2 cert)
+ {
+ var path = Path.Combine(root, "титул.docx");
+ CreateDocument(path, (main, body) =>
+ {
+ var titleFooter = AddFooter(main, "Подвал титула");
+ body.Append(Para("Раздел 1"),
+ new Paragraph(new ParagraphProperties(Section(main, firstFooter: titleFooter, titlePage: true))));
+ body.Append(Para("Раздел 2"), Section(main, titlePage: true));
+ });
+
+ var output = WordStamper.CreateStampedCopy(path, new[] { cert },
+ new PdfStamper.StampOptions { Pages = PdfStamper.StampPages.First }, DateTime.Now);
+ Validate(output);
+
+ using var doc = WordprocessingDocument.Open(output, false);
+ var main = doc.MainDocumentPart!;
+ var sections = main.Document!.Body!.Descendants().ToList();
+ var first = (FooterPart)main.GetPartById(Ref(sections[0], HeaderFooterValues.First)!);
+ var second = (FooterPart)main.GetPartById(Ref(sections[1], HeaderFooterValues.First)!);
+ Assert(Count(first.Footer!, Title) == 1 && first.Footer!.InnerText.Contains("Подвал титула"),
+ "stamp goes into a copy of the title footer");
+ Assert(Count(second.Footer!, Title) == 0 && second.Footer!.InnerText.Contains("Подвал титула"),
+ "the second section keeps the old title footer");
+ Assert(sections[0].Elements().Count() == 1, "no duplicate titlePg");
+ }
+
+ // «Все страницы»: обычный, первой страницы и чётных — в каждом видимом подвале
+ // по плашке на подписанта; недостающие подвалы создаются.
+ private static void CheckAllFooters(string root, X509Certificate2 certA, X509Certificate2 certB, string logo)
+ {
+ var path = Path.Combine(root, "все страницы.docx");
+ CreateDocument(path, (main, body) =>
+ {
+ var settings = main.AddNewPart();
+ settings.Settings = new Settings(new EvenAndOddHeaders());
+ var f1 = AddFooter(main, "Подвал раздела 1");
+ var f2 = AddFooter(main, "Подвал раздела 2");
+ body.Append(DocumentTable(),
+ new Paragraph(new ParagraphProperties(Section(main, footer: f1, titlePage: true))));
+ body.Append(Para("Раздел 2"), Section(main, footer: f2, landscape: true));
+ });
+
+ var output = WordStamper.CreateStampedCopy(path, new[] { certA, certB },
+ new PdfStamper.StampOptions { Pages = PdfStamper.StampPages.All, LogoPath = logo }, DateTime.Now);
+ Validate(output);
+ Keep(output);
+
+ using var doc = WordprocessingDocument.Open(output, false);
+ var main = doc.MainDocumentPart!;
+ var sections = main.Document!.Body!.Descendants().ToList();
+ string Shown(int section, HeaderFooterValues type)
+ {
+ for (var i = section; i >= 0; i--)
+ if (Ref(sections[i], type) is { } id)
+ return id;
+ throw new Exception($"word stamp: no {((IEnumValue)type).Value} footer for section {section + 1}");
+ }
+
+ var shown = new[]
+ {
+ Shown(0, HeaderFooterValues.Default), Shown(0, HeaderFooterValues.First), Shown(0, HeaderFooterValues.Even),
+ Shown(1, HeaderFooterValues.Default), Shown(1, HeaderFooterValues.Even),
+ };
+ foreach (var id in shown)
+ {
+ var footer = ((FooterPart)main.GetPartById(id)).Footer!;
+ Assert(Count(footer, Title) == 2, $"footer {id} must carry both stamps once: {Count(footer, Title)}");
+ Assert(footer.Descendants().Count() == 2, "logo on each stamp in the footer");
+ }
+
+ Assert(((FooterPart)main.GetPartById(Shown(0, HeaderFooterValues.Default))).Footer!.InnerText.Contains("Подвал раздела 1")
+ && ((FooterPart)main.GetPartById(Shown(1, HeaderFooterValues.Default))).Footer!.InnerText.Contains("Подвал раздела 2"),
+ "existing footer content stays");
+ var ids = DrawingIds(main);
+ Assert(ids.Count == ids.Distinct().Count(), "drawing ids stay unique");
+ }
+
+ private static void CheckFormats(string root, X509Certificate2 cert)
+ {
+ Assert(DocumentStamper.IsSupported("а.DOCX") && DocumentStamper.IsSupported("б.docm")
+ && DocumentStamper.IsSupported("в.pdf") && !DocumentStamper.IsSupported("г.doc"),
+ "supported formats: pdf, docx, docm");
+ Assert(DocumentStamper.Note("г.doc", new PdfStamper.StampOptions())!.Contains(".docx"), "old .doc: save as .docx");
+ Assert(DocumentStamper.Note("д.xlsx", new PdfStamper.StampOptions())!.Contains("PDF"), "other formats: no stamp");
+ Assert(DocumentStamper.Note("е.pdf", new PdfStamper.StampOptions { Pages = PdfStamper.StampPages.Custom }) is null,
+ "PDF honours page numbers");
+
+ var custom = new PdfStamper.StampOptions { Pages = PdfStamper.StampPages.Custom, CustomPages = "2" };
+ Assert(DocumentStamper.Note("ж.docx", custom)!.Contains("в конце текста"), "Word explains custom pages");
+ var path = Path.Combine(root, "страницы.docx");
+ CreateDocument(path, (main, body) => body.Append(Para("Текст")));
+ var output = DocumentStamper.CreateStampedCopy(path, new[] { cert }, custom, DateTime.Now);
+ using (var doc = WordprocessingDocument.Open(output, false))
+ Assert(Count(doc.MainDocumentPart!.Document!.Body!, Title) == 1, "custom pages → end of text");
+ Validate(output);
+
+ // текст в две колонки: плашка по ширине колонки, а не страницы
+ var columns = Path.Combine(root, "колонки.docx");
+ CreateDocument(columns, (main, body) =>
+ {
+ var section = Section(main);
+ section.InsertAfter(new Columns { ColumnCount = 2, Space = "708" }, section.GetFirstChild()!);
+ body.Append(Para("Левая колонка"), section);
+ });
+ var columnsOutput = WordStamper.CreateStampedCopy(columns, new[] { cert, cert }, new PdfStamper.StampOptions(), DateTime.Now);
+ Validate(columnsOutput);
+ using (var doc = WordprocessingDocument.Open(columnsOutput, false))
+ {
+ var layout = doc.MainDocumentPart!.Document!.Body!.Elements().Single();
+ var width = int.Parse(layout.GetFirstChild()!.GetFirstChild()!.Width!.Value!);
+ Assert(width <= (9355 - 708) / 2 && layout.Elements().Count() == 2,
+ $"stamps fit one text column, one per row: width {width}");
+ }
+ }
+
+ // Подписание с галочкой «Штамп»: по умолчанию подписывается оригинал, копия —
+ // без подписи со всеми подписантами; в режиме «подписывать копию» подпись
+ // относится к штампованному файлу.
+ private static async Task CheckSigning(string root, DocumentSigner signer,
+ X509Certificate2 certA, X509Certificate2 certB, string logo)
+ {
+ var dir = Path.Combine(root, "signing");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "акт.docx");
+ CreateDocument(path, (main, body) => body.Append(Para("Акт выполненных работ"), Section(main)));
+ var original = await File.ReadAllBytesAsync(path);
+
+ var first = await signer.SignFileAsync(path, certA, new DocumentSigner.SignOptions
+ {
+ Detached = true, Stamp = true, StampLogoPath = logo,
+ });
+ Assert(first.SignedDocumentPath == path && first.SignaturePath == path + ".sig", "the original is signed");
+ Assert(first.StampedCopyPath == Path.Combine(dir, "акт (со штампом).docx") && File.Exists(first.StampedCopyPath),
+ "unsigned stamped Word copy is created");
+ Assert(!File.Exists(first.StampedCopyPath + ".sig"), "the separate copy is not signed");
+
+ var second = await signer.SignFileAsync(path, certB, new DocumentSigner.SignOptions
+ {
+ Detached = true, MergeWithExisting = true, Stamp = true, StampLogoPath = logo,
+ });
+ Assert(second.SignerCount == 2 && (await File.ReadAllBytesAsync(path)).SequenceEqual(original),
+ "co-signing keeps the original intact");
+ var cms = new SignedCms(new ContentInfo(original), detached: true);
+ cms.Decode(await File.ReadAllBytesAsync(second.SignaturePath));
+ cms.CheckSignature(verifySignatureOnly: true);
+ using (var doc = WordprocessingDocument.Open(second.StampedCopyPath!, false))
+ {
+ var body = doc.MainDocumentPart!.Document!.Body!;
+ Assert(Count(body, Title) == 2 && body.InnerText.Contains("Иванов") && body.InnerText.Contains("Петрова"),
+ "the copy lists every signer of the final .sig");
+ }
+
+ Validate(second.StampedCopyPath!);
+
+ var copy = await signer.SignFileAsync(path, certA, new DocumentSigner.SignOptions
+ {
+ Detached = true, Stamp = true, StampSignCopy = true,
+ });
+ Assert(copy.SignedDocumentPath == first.StampedCopyPath && copy.SignaturePath == first.StampedCopyPath + ".sig",
+ "sign-copy mode signs the stamped Word copy");
+ var signedCopy = new SignedCms(new ContentInfo(await File.ReadAllBytesAsync(copy.SignedDocumentPath)), detached: true);
+ signedCopy.Decode(await File.ReadAllBytesAsync(copy.SignaturePath));
+ signedCopy.CheckSignature(verifySignatureOnly: true);
+ Assert(CmsMerger.CountSigners(await File.ReadAllBytesAsync(path + ".sig")) == 2,
+ "signing the copy leaves the original's signature alone");
+
+ // подписанная копия больше не пересоздаётся — ни при подписании, ни инструментом «Штамп»
+ var signedSig = await File.ReadAllBytesAsync(path + ".sig");
+ var signedCopyBytes = await File.ReadAllBytesAsync(copy.SignedDocumentPath);
+ foreach (var signCopy in new[] { false, true })
+ {
+ try
+ {
+ await signer.SignFileAsync(path, certB, new DocumentSigner.SignOptions
+ {
+ Detached = true, MergeWithExisting = true, Stamp = true, StampSignCopy = signCopy,
+ });
+ throw new Exception("word stamp: a signed stamped copy must not be overwritten");
+ }
+ catch (InvalidOperationException e) when (e.Message.Contains("уже подписана"))
+ {
+ }
+ }
+
+ try
+ {
+ DocumentStamper.CreateStampedCopy(path, new[] { certB }, new PdfStamper.StampOptions(), DateTime.Now);
+ throw new Exception("word stamp: the stamp tool must not overwrite a signed copy");
+ }
+ catch (InvalidOperationException e) when (e.Message.Contains("уже подписана"))
+ {
+ }
+
+ Assert((await File.ReadAllBytesAsync(path + ".sig")).SequenceEqual(signedSig)
+ && (await File.ReadAllBytesAsync(copy.SignedDocumentPath)).SequenceEqual(signedCopyBytes),
+ "refused signing writes nothing: the original's .sig and the signed copy stay as they were");
+ }
+
+ private static async Task CheckSignCopyInViewModel(string root, X509Certificate2 cert)
+ {
+ var dir = Path.Combine(root, "vm");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "письмо.docx");
+ CreateDocument(path, (main, body) => body.Append(Para("Письмо")));
+ var settings = new AppSettings(Path.Combine(dir, "settings")) { CheckUpdatesOnStart = false };
+ var vm = new MainWindowViewModel(new CertificateProvider(), new DocumentSigner(), settings,
+ new CertificateVault(Path.Combine(dir, "vault")));
+ vm.UseStamp = true;
+ vm.StampSignCopy = true;
+ vm.RequestStampOptionsAsync = _ => Task.FromResult(
+ new SignService.Views.StampOptionsDialog.Result(vm.BuildInitialStampOptions(), SignCopy: true));
+ await vm.AddFilesAsync(path);
+ var item = vm.Files.Single();
+ Assert(item.SignsStampedCopy && item.StatusDisplay.Contains("копия со штампом"),
+ "Word in sign-copy mode signs the stamped copy: " + item.StatusDisplay);
+ vm.SelectedCertificate = new CertificateItem(cert);
+ await vm.SignAllCommand.ExecuteAsync(null);
+ Assert(item.Status == SignStatus.Signed && item.SignaturePath == Path.Combine(dir, "письмо (со штампом).docx.sig"),
+ "stamped Word copy signed from the main window: " + item.StatusDisplay + " " + item.Message);
+ }
+
+ // У каждой плашки — своя дата подписания (из подписи), номер МЧД — только у того,
+ // кто подписывал по ней сейчас.
+ private static async Task CheckPlatesPerSigner(string root, DocumentSigner signer,
+ X509Certificate2 certA, X509Certificate2 certB)
+ {
+ var dir = Path.Combine(root, "plates");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "счёт.docx");
+ CreateDocument(path, (main, body) => body.Append(Para("Счёт"), Section(main)));
+ var data = await File.ReadAllBytesAsync(path);
+ // подпись А «вчера»: время подписания берётся из подписи, а не из момента пересоздания копии
+ var yesterday = DateTime.Now.AddDays(-1);
+ var signedYesterday = new SignedCms(new ContentInfo(data), detached: true);
+ var cmsSigner = new CmsSigner(certA);
+ cmsSigner.SignedAttributes.Add(new Pkcs9SigningTime(yesterday));
+ signedYesterday.ComputeSignature(cmsSigner);
+ await File.WriteAllBytesAsync(path + ".sig", signedYesterday.Encode());
+
+ var result = await signer.SignFileAsync(path, certB, new DocumentSigner.SignOptions
+ {
+ Detached = true, MergeWithExisting = true, Stamp = true,
+ StampParameters = new PdfStamper.StampOptions { PoaNumber = "МЧД-Б" },
+ });
+ Assert(result.SignerCount == 2 && result.StampWarning is null, "co-signed: " + result.StampWarning);
+ using var doc = WordprocessingDocument.Open(result.StampedCopyPath!, false);
+ var plates = doc.MainDocumentPart!.Document!.Body!.Descendants()
+ .Where(t => t.Descendants().Count() == 0 && t.InnerText.Contains(Title))
+ .Select(t => t.InnerText)
+ .ToList();
+ var plateA = plates.Single(t => t.Contains("Иванов"));
+ var plateB = plates.Single(t => t.Contains("Петрова"));
+ Assert(plateA.Contains($"Дата подписания: {yesterday:dd.MM.yyyy HH:mm}") && !plateA.Contains("МЧД"),
+ "the earlier signer keeps their own date and no one else's МЧД: " + plateA);
+ Assert(plateB.Contains("МЧД № МЧД-Б") && plateB.Contains($"Дата подписания: {DateTime.Now:dd.MM.yyyy}"),
+ "the current signer's plate carries their МЧД: " + plateB);
+ }
+
+ // «Штамп» без подписания: плашки только по подписям, которые проверяются по
+ // документу; подпись под другой версией — отказ; подпись с другим именем
+ // находится по содержимому; сертификат для этого не нужен.
+ private static async Task CheckStampToolUsesValidSignaturesOnly(string root, DocumentSigner signer,
+ X509Certificate2 certA, X509Certificate2 certB)
+ {
+ var dir = Path.Combine(root, "stamp-tool");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "приказ.docx");
+ CreateDocument(path, (main, body) => body.Append(Para("Приказ № 1"), Section(main)));
+ var stale = signer.Sign(System.Text.Encoding.UTF8.GetBytes("прежняя редакция приказа"), certB);
+ var settings = new AppSettings(Path.Combine(dir, "settings")) { CheckUpdatesOnStart = false };
+ var vm = new MainWindowViewModel(new CertificateProvider(), new DocumentSigner(), settings,
+ new CertificateVault(Path.Combine(dir, "vault")));
+ Assert(vm.StampOnlyCommand.CanExecute(null), "the stamp tool does not need a certificate");
+
+ await File.WriteAllBytesAsync(path + ".sig", stale);
+ await vm.StampWithoutSigningAsync(new[] { path }, new PdfStamper.StampOptions());
+ Assert(!File.Exists(WordStamper.StampedCopyPath(path)) && vm.LogText.Contains("не соответствуют"),
+ "a signature of another version must not produce «ДОКУМЕНТ ПОДПИСАН»: " + vm.LogText);
+
+ var data = await File.ReadAllBytesAsync(path);
+ await File.WriteAllBytesAsync(path + ".sig", CmsMerger.Merge(new[] { signer.Sign(data, certA), stale }));
+ await vm.StampWithoutSigningAsync(new[] { path }, new PdfStamper.StampOptions());
+ using (var doc = WordprocessingDocument.Open(WordStamper.StampedCopyPath(path), false))
+ {
+ var text = doc.MainDocumentPart!.Document!.Body!.InnerText;
+ Assert(Count(doc.MainDocumentPart.Document.Body!, Title) == 1 && text.Contains("Иванов") && !text.Contains("Петрова"),
+ "only the signer whose signature matches gets a plate");
+ }
+
+ Assert(vm.LogText.Contains("без плашки") && vm.LogText.Contains("Петрова"), "the skipped signer is reported: " + vm.LogText);
+
+ // подпись под другим именем в той же папке находится по содержимому
+ File.Delete(path + ".sig");
+ File.Delete(WordStamper.StampedCopyPath(path));
+ await File.WriteAllBytesAsync(Path.Combine(dir, "подпись руководителя.sig"), signer.Sign(data, certB));
+ await vm.StampWithoutSigningAsync(new[] { path }, new PdfStamper.StampOptions());
+ using (var doc = WordprocessingDocument.Open(WordStamper.StampedCopyPath(path), false))
+ Assert(doc.MainDocumentPart!.Document!.Body!.InnerText.Contains("Петрова"),
+ "a signature found by content is used for the plates: " + vm.LogText);
+ }
+
+ private static async Task CheckBrokenDocument(string root, DocumentSigner signer, X509Certificate2 cert)
+ {
+ var dir = Path.Combine(root, "broken");
+ Directory.CreateDirectory(dir);
+ var path = Path.Combine(dir, "битый.docx");
+ await File.WriteAllBytesAsync(path, new byte[] { 0xD0, 0xCF, 0x11, 0xE0, 1, 2, 3 }); // как зашифрованный docx
+ try
+ {
+ await signer.SignFileAsync(path, cert, new DocumentSigner.SignOptions { Detached = true, Stamp = true, StampSignCopy = true });
+ throw new Exception("word stamp: a broken document must not be signed in sign-copy mode");
+ }
+ catch (InvalidOperationException e) when (e.Message.Contains("документ Word"))
+ {
+ }
+
+ Assert(Directory.GetFiles(dir).Length == 1, "nothing is written for a broken document: "
+ + string.Join(", ", Directory.GetFiles(dir).Select(Path.GetFileName)));
+ }
+
+ // ===== Построение тестовых документов =====
+
+ private static void CreateDocument(string path, Action fill, bool macro = false)
+ {
+ using var doc = WordprocessingDocument.Create(path,
+ macro ? WordprocessingDocumentType.MacroEnabledDocument : WordprocessingDocumentType.Document);
+ var main = doc.AddMainDocumentPart();
+ main.Document = new Document(new Body());
+ // «Обычный» как в типовом бланке: красная строка, по ширине, полуторный интервал.
+ main.AddNewPart().Styles = new Styles(
+ new Style(
+ new StyleName { Val = "Normal" },
+ new StyleParagraphProperties(
+ new SpacingBetweenLines { After = "200", Line = "360", LineRule = LineSpacingRuleValues.Auto },
+ new Indentation { FirstLine = "709" },
+ new Justification { Val = JustificationValues.Both }),
+ new StyleRunProperties(new FontSize { Val = "28" }))
+ { Type = StyleValues.Paragraph, StyleId = "Normal", Default = true });
+ fill(main, main.Document.Body!);
+ }
+
+ private static Paragraph Para(string text) => new(new Run(new Text(text)));
+
+ private static Table DocumentTable() => new(
+ new TableProperties(new TableWidth { Width = "5000", Type = TableWidthUnitValues.Pct }),
+ new TableGrid(new GridColumn { Width = "4677" }, new GridColumn { Width = "4678" }),
+ new TableRow(new TableCell(Para("Поставщик")), new TableCell(Para("Покупатель"))));
+
+ private static SectionProperties Section(MainDocumentPart main, string? header = null, string? footer = null,
+ string? firstFooter = null, bool titlePage = false, bool landscape = false)
+ {
+ var section = new SectionProperties();
+ if (header is not null)
+ section.Append(new HeaderReference { Type = HeaderFooterValues.Default, Id = header });
+ if (footer is not null)
+ section.Append(new FooterReference { Type = HeaderFooterValues.Default, Id = footer });
+ if (firstFooter is not null)
+ section.Append(new FooterReference { Type = HeaderFooterValues.First, Id = firstFooter });
+ section.Append(
+ landscape
+ ? new PageSize { Width = 16838, Height = 11906, Orient = PageOrientationValues.Landscape }
+ : new PageSize { Width = 11906, Height = 16838 },
+ new PageMargin { Top = 1134, Right = 850, Bottom = 1134, Left = 1701, Header = 708, Footer = 708, Gutter = 0 });
+ if (titlePage)
+ section.Append(new TitlePage());
+ return section;
+ }
+
+ private static string AddFooter(MainDocumentPart main, string text, bool pageField = false)
+ {
+ var part = main.AddNewPart();
+ var paragraph = new Paragraph(new ParagraphProperties(new Justification { Val = JustificationValues.Center }),
+ new Run(new Text(text) { Space = SpaceProcessingModeValues.Preserve }));
+ if (pageField)
+ paragraph.Append(new SimpleField(new Run(new Text("1"))) { Instruction = " PAGE " });
+ part.Footer = new Footer(paragraph);
+ return main.GetIdOfPart(part);
+ }
+
+ private static string AddHeader(MainDocumentPart main, string text, byte[] png)
+ {
+ var part = main.AddNewPart();
+ var image = part.AddImagePart(ImagePartType.Png);
+ using (var stream = new MemoryStream(png))
+ image.FeedData(stream);
+ const long size = 360000;
+ var drawing = new Drawing(new DW.Inline(
+ new DW.Extent { Cx = size, Cy = size },
+ new DW.DocProperties { Id = 1U, Name = "Герб" },
+ new DocumentFormat.OpenXml.Drawing.Graphic(new DocumentFormat.OpenXml.Drawing.GraphicData(
+ new DocumentFormat.OpenXml.Drawing.Pictures.Picture(
+ new DocumentFormat.OpenXml.Drawing.Pictures.NonVisualPictureProperties(
+ new DocumentFormat.OpenXml.Drawing.Pictures.NonVisualDrawingProperties { Id = 0U, Name = "Герб" },
+ new DocumentFormat.OpenXml.Drawing.Pictures.NonVisualPictureDrawingProperties()),
+ new DocumentFormat.OpenXml.Drawing.Pictures.BlipFill(
+ new DocumentFormat.OpenXml.Drawing.Blip { Embed = part.GetIdOfPart(image) },
+ new DocumentFormat.OpenXml.Drawing.Stretch(new DocumentFormat.OpenXml.Drawing.FillRectangle())),
+ new DocumentFormat.OpenXml.Drawing.Pictures.ShapeProperties(
+ new DocumentFormat.OpenXml.Drawing.Transform2D(
+ new DocumentFormat.OpenXml.Drawing.Offset { X = 0, Y = 0 },
+ new DocumentFormat.OpenXml.Drawing.Extents { Cx = size, Cy = size }),
+ new DocumentFormat.OpenXml.Drawing.PresetGeometry { Preset = DocumentFormat.OpenXml.Drawing.ShapeTypeValues.Rectangle })))
+ { Uri = "http://schemas.openxmlformats.org/drawingml/2006/picture" })));
+ part.Header = new Header(new Paragraph(new Run(drawing), new Run(new Text(" " + text) { Space = SpaceProcessingModeValues.Preserve })));
+ return main.GetIdOfPart(part);
+ }
+
+ private static string WriteLogo(string dir)
+ {
+ var path = Path.Combine(dir, "логотип.png");
+ using var surface = SkiaSharp.SKSurface.Create(new SkiaSharp.SKImageInfo(640, 320));
+ surface.Canvas.Clear(SkiaSharp.SKColors.Transparent);
+ using var paint = new SkiaSharp.SKPaint { Color = new SkiaSharp.SKColor(0x1B, 0x4F, 0x9C), IsAntialias = true };
+ surface.Canvas.DrawRoundRect(new SkiaSharp.SKRect(10, 10, 630, 310), 60, 60, paint);
+ using var snapshot = surface.Snapshot();
+ using var png = snapshot.Encode(SkiaSharp.SKEncodedImageFormat.Png, 100);
+ File.WriteAllBytes(path, png.ToArray());
+ return path;
+ }
+
+ // ===== Проверки =====
+
+ private static void Validate(string path)
+ {
+ using var doc = WordprocessingDocument.Open(path, false);
+ var errors = new OpenXmlValidator(FileFormatVersions.Office2019).Validate(doc).ToList();
+ Assert(errors.Count == 0, $"«{Path.GetFileName(path)}» fails OpenXML validation:\n" + string.Join("\n",
+ errors.Take(10).Select(e => $" {e.Part?.Uri} {e.Path?.XPath}: {e.Description}")));
+ }
+
+ private static string? Ref(SectionProperties section, HeaderFooterValues type) where T : HeaderFooterReferenceType =>
+ section.Elements().FirstOrDefault(r => r.Type?.Value == type)?.Id?.Value;
+
+ private static int Count(OpenXmlElement root, string text) =>
+ root.Descendants().Count(t => t.Text == text);
+
+ private static List DrawingIds(MainDocumentPart main) =>
+ new OpenXmlElement[] { main.Document! }
+ .Concat(main.HeaderParts.Select(h => (OpenXmlElement)h.Header!))
+ .Concat(main.FooterParts.Select(f => (OpenXmlElement)f.Footer!))
+ .SelectMany(r => r.Descendants())
+ .Select(d => d.Id!.Value)
+ .ToList();
+
+ private static void Keep(string path)
+ {
+ if (Environment.GetEnvironmentVariable("SIGNSERVICE_TEST_OUTPUT") is { Length: > 0 } dir)
+ {
+ Directory.CreateDirectory(dir);
+ File.Copy(path, Path.Combine(dir, Path.GetFileName(path)), overwrite: true);
+ }
+ }
+
+ private static X509Certificate2 MakeCert(string subject)
+ {
+ using var key = RSA.Create(2048);
+ return new CertificateRequest(subject, key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)
+ .CreateSelfSigned(DateTimeOffset.Now.AddDays(-1), DateTimeOffset.Now.AddYears(1));
+ }
+
+ private static void Assert(bool condition, string message)
+ {
+ if (!condition)
+ throw new Exception("word stamp: " + message);
+ }
+}