From 7c1e700d252f4d1df4fe31186827be69f9bcd91c Mon Sep 17 00:00:00 2001 From: Perricheno Date: Thu, 22 Oct 2026 13:24:40 +0500 Subject: [PATCH 1/4] Remove empty Calc PDF pages --- src/Filee.App/Assets/i18n/en.json | 1 + src/Filee.App/Assets/i18n/ru.json | 1 + .../Office/LibreOfficeConverter.cs | 21 +++++ src/Filee.Engines/Pdf/PdfBlankPages.cs | 85 +++++++++++++++++++ .../Filee.Engines.Tests/PdfBlankPagesTests.cs | 63 ++++++++++++++ 5 files changed, 171 insertions(+) create mode 100644 src/Filee.Engines/Pdf/PdfBlankPages.cs create mode 100644 tests/Filee.Engines.Tests/PdfBlankPagesTests.cs diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index d6024c303..6bf226d09 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "This kind of shape isn't supported", "error.geo_too_large": "The Shapefile would pass the format's 2 GB limit", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", + "notice.empty_spreadsheet_pages_removed": "Empty spreadsheet pages were removed", "notice.live_photo_pair": "Saved as a photo and a MOV with the same name. Import both together (iCloud Photos, AirDrop or Photos on a Mac) to get a Live Photo", "notice.geo_datum_assumed": "The datum isn't WGS 84; positions were converted as if it were and can be off by up to hundreds of metres", "notice.geo_gpx_lossy": "GPX holds only points, routes and tracks with a few fields: other attributes were left out and polygons became tracks", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index 3eb526c74..0e3c4e2ab 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "Такой тип фигуры не поддерживается", "error.geo_too_large": "Shapefile превысил бы предел формата в 2 ГБ", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", + "notice.empty_spreadsheet_pages_removed": "Пустые страницы таблицы удалены", "notice.live_photo_pair": "Сохранено как фото и MOV с одинаковым именем. Импортируйте их вместе (Фото iCloud, AirDrop или «Фото» на Mac), чтобы получить Live Photo", "notice.geo_datum_assumed": "Датум отличается от WGS 84; координаты пересчитаны как для WGS 84 и могут сместиться на сотни метров", "notice.geo_gpx_lossy": "GPX хранит только точки, маршруты и треки с небольшим набором полей: остальные атрибуты не вошли, а полигоны стали треками", diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index 68aa84d1e..3cda80106 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -20,6 +20,7 @@ using Filee.Core.Conversion; using Filee.Core.Formats; using Filee.Engines.Infrastructure; +using Filee.Engines.Pdf; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using PdfSharp.Pdf; @@ -122,6 +123,26 @@ public async Task> ConvertAsync(ConversionStep step, IProg throw new InvalidOperationException( $"LibreOffice produced no output (exit {result.ExitCode}). {result.StandardError.Trim()}"); + // Calc sometimes turns stale Excel print tiles into completely empty trailing PDF pages. Only remove pages + // with no painting operation at all; pages containing text, borders, charts, pictures or annotations stay. + if (step.To == "pdf" && FamilyOf(step.From, FormatRegistry.ExtensionOf(step.InputPath)) == "calc") + { + try + { + var removed = PdfBlankPages.Remove(produced); + if (removed > 0) + { + _logger.LogInformation("{File}: removed {Count} empty spreadsheet PDF pages", step.InputPath, removed); + step.Notice?.Invoke("notice.empty_spreadsheet_pages_removed"); + } + } + catch (Exception e) when (e is not OperationCanceledException) + { + // Post-processing is an improvement, not a reason to discard LibreOffice's otherwise valid PDF. + _logger.LogInformation("{File}: could not check empty spreadsheet PDF pages: {Message}", step.InputPath, e.Message); + } + } + var target = step.Output.Allocate(step.To); if (target is null) return []; diff --git a/src/Filee.Engines/Pdf/PdfBlankPages.cs b/src/Filee.Engines/Pdf/PdfBlankPages.cs new file mode 100644 index 000000000..edfe17580 --- /dev/null +++ b/src/Filee.Engines/Pdf/PdfBlankPages.cs @@ -0,0 +1,85 @@ +// Removes pages that contain no painting operations at all. This is deliberately stricter than "no text": charts, +// borders, pictures, annotations and even a deliberately painted white rectangle keep a page. LibreOffice Calc can +// append empty tiles when an imported spreadsheet's saved print range is wider or taller than its actual content. + +using PdfSharp.Pdf; +using PdfSharp.Pdf.Content; +using PdfSharp.Pdf.Content.Objects; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Pdf; + +internal static class PdfBlankPages +{ + /// Operators that can put a visible mark on a page. + private static readonly HashSet Paint = + [ + // Stroke / fill the current path. + OpCodeName.S, OpCodeName.s, OpCodeName.f, OpCodeName.F, OpCodeName.fx, + OpCodeName.B, OpCodeName.Bx, OpCodeName.b, OpCodeName.bx, + // XObjects, inline images and shadings. + OpCodeName.Do, OpCodeName.BI, OpCodeName.sh, + // Show text. Count it even when it happens to be empty or invisible: uncertain pages must stay. + OpCodeName.Tj, OpCodeName.TJ, OpCodeName.QuoteSingle, OpCodeName.QuoteDouble, OpCodeName.Tx, + ]; + + /// + /// Removes pages with no painting operators from , replacing it only after the cleaned PDF + /// was saved successfully. A wholly blank document is left alone: a valid PDF still needs a page, and an empty + /// workbook may have meant to produce one. + /// + /// The number of pages removed. + public static int Remove(string path) + { + var fullPath = Path.GetFullPath(path); + var temp = Path.Combine(Path.GetDirectoryName(fullPath)!, $".{Path.GetFileNameWithoutExtension(path)}-{Guid.NewGuid():N}.pdf"); + try + { + int removed; + using (var document = PdfReader.Open(fullPath, PdfDocumentOpenMode.Modify)) + { + var blank = Enumerable.Range(0, document.PageCount).Where(i => IsBlank(document.Pages[i])).ToList(); + if (blank.Count == 0 || blank.Count == document.PageCount) + return 0; + + for (var i = blank.Count - 1; i >= 0; i--) + document.Pages.RemoveAt(blank[i]); + removed = blank.Count; + document.Save(temp); + } + File.Move(temp, fullPath, overwrite: true); + return removed; + } + finally + { + try { File.Delete(temp); } catch (IOException) { } catch (UnauthorizedAccessException) { } + } + } + + /// Unreadable content is not blank: uncertainty must never delete a user's page. + internal static bool IsBlank(PdfPage page) + { + if (page.Elements.GetArray("/Annots") is { Elements.Count: > 0 }) + return false; + try + { + return !Paints(ContentReader.ReadContent(page)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return false; + } + } + + private static bool Paints(CSequence content) + { + foreach (var item in content) + { + if (item is COperator op && Paint.Contains(op.OpCode.OpCodeName)) + return true; + if (item is CSequence nested && Paints(nested)) + return true; + } + return false; + } +} diff --git a/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs b/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs new file mode 100644 index 000000000..1efd09203 --- /dev/null +++ b/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs @@ -0,0 +1,63 @@ +using Filee.Engines.Pdf; +using PdfSharp.Drawing; +using PdfSharp.Pdf; +using PdfSharp.Pdf.IO; + +namespace Filee.Engines.Tests; + +public class PdfBlankPagesTests +{ + [Fact] + public void Removes_only_pages_without_any_painting_operation() + { + var path = Path.Combine(Path.GetTempPath(), $"filee-blank-pages-{Guid.NewGuid():N}.pdf"); + try + { + using (var document = new PdfDocument()) + { + document.AddPage(); // removed + var line = document.AddPage(); + using (var graphics = XGraphics.FromPdfPage(line)) + graphics.DrawLine(XPens.Black, 10, 10, 100, 100); + document.AddPage(); // removed + var white = document.AddPage(); + using (var graphics = XGraphics.FromPdfPage(white)) + graphics.DrawRectangle(XBrushes.White, 10, 10, 100, 100); + document.Save(path); + } + + Assert.Equal(2, PdfBlankPages.Remove(path)); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + for (var i = 0; i < result.PageCount; i++) + Assert.False(PdfBlankPages.IsBlank(result.Pages[i])); + } + finally + { + File.Delete(path); + } + } + + [Fact] + public void Keeps_a_wholly_blank_document() + { + var path = Path.Combine(Path.GetTempPath(), $"filee-blank-document-{Guid.NewGuid():N}.pdf"); + try + { + using (var document = new PdfDocument()) + { + document.AddPage(); + document.AddPage(); + document.Save(path); + } + + Assert.Equal(0, PdfBlankPages.Remove(path)); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + } + finally + { + File.Delete(path); + } + } +} From 855af4830698c6bbdbcc70fc56af9fabf968df87 Mon Sep 17 00:00:00 2001 From: Perricheno Date: Thu, 22 Oct 2026 13:45:45 +0500 Subject: [PATCH 2/4] Translate the empty spreadsheet page notice --- src/Filee.App/Assets/i18n/ar.json | 1 + src/Filee.App/Assets/i18n/es.json | 1 + src/Filee.App/Assets/i18n/fr.json | 1 + src/Filee.App/Assets/i18n/hi.json | 1 + src/Filee.App/Assets/i18n/ja.json | 1 + src/Filee.App/Assets/i18n/ko.json | 1 + src/Filee.App/Assets/i18n/pl.json | 1 + src/Filee.App/Assets/i18n/pt-BR.json | 1 + src/Filee.App/Assets/i18n/zh-CN.json | 1 + src/Filee.App/Assets/i18n/zh-TW.json | 1 + 10 files changed, 10 insertions(+) diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 7ee866f47..3a07f96ca 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -99,6 +99,7 @@ "error.geo_unsupported_shape": "هذا النوع من الأشكال غير مدعوم", "error.geo_too_large": "سيتجاوز ملف Shapefile حد الصيغة البالغ 2 غيغابايت", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", + "notice.empty_spreadsheet_pages_removed": "تمت إزالة صفحات جدول البيانات الفارغة", "notice.live_photo_pair": "حُفظ كصورة وملف MOV بالاسم نفسه. استوردهما معًا (صور iCloud أو AirDrop أو تطبيق الصور على Mac) لتحصل على Live Photo", "notice.geo_datum_assumed": "المرجع الجيوديسي ليس WGS 84؛ حُوّلت المواقع على افتراض أنه كذلك، وقد تنحرف بمقدار يصل إلى مئات الأمتار", "notice.geo_gpx_lossy": "تحتفظ GPX بالنقاط والمسارات والطرق مع حقول محدودة فقط: حُذفت السمات الأخرى وتحولت المضلعات إلى مسارات", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 89007a3ee..a2ea76b80 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -99,6 +99,7 @@ "error.geo_unsupported_shape": "Este tipo de forma no es compatible", "error.geo_too_large": "El Shapefile superaría el límite de 2 GB del formato", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", + "notice.empty_spreadsheet_pages_removed": "Se eliminaron las páginas vacías de la hoja de cálculo", "notice.live_photo_pair": "Guardado como una foto y un MOV con el mismo nombre. Impórtalos juntos (Fotos de iCloud, AirDrop o Fotos en un Mac) para obtener una Live Photo", "notice.geo_datum_assumed": "El datum no es WGS 84; las posiciones se convirtieron como si lo fuera y pueden desviarse hasta cientos de metros", "notice.geo_gpx_lossy": "GPX solo guarda puntos, rutas y tracks con pocos campos: se omitieron los demás atributos y los polígonos pasaron a ser tracks", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index 321978517..825234cf0 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "Ce type de forme n'est pas pris en charge", "error.geo_too_large": "Le Shapefile dépasserait la limite de 2 Go du format", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", + "notice.empty_spreadsheet_pages_removed": "Les pages vides de la feuille de calcul ont été supprimées", "notice.live_photo_pair": "Enregistré en une photo et un MOV de même nom. Importez-les ensemble (Photos iCloud, AirDrop ou Photos sur Mac) pour obtenir une Live Photo", "notice.geo_datum_assumed": "Le datum n'est pas WGS 84 ; les positions ont été converties comme s'il l'était et peuvent être décalées de plusieurs centaines de mètres", "notice.geo_gpx_lossy": "GPX ne contient que des points, des routes et des traces avec quelques champs : les autres attributs ont été omis et les polygones sont devenus des traces", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index dde786072..913544fe1 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -99,6 +99,7 @@ "error.geo_unsupported_shape": "इस तरह का आकार समर्थित नहीं है", "error.geo_too_large": "Shapefile फ़ॉर्मैट की 2 GB सीमा से बड़ी हो जाएगी", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", + "notice.empty_spreadsheet_pages_removed": "स्प्रेडशीट के खाली पृष्ठ हटा दिए गए", "notice.live_photo_pair": "एक ही नाम की फ़ोटो और MOV के रूप में सहेजा गया। लाइव फ़ोटो पाने के लिए दोनों को साथ में इम्पोर्ट करें (iCloud Photos, AirDrop या Mac पर Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 नहीं है। स्थितियाँ ऐसे कन्वर्ट की गईं मानो वह WGS 84 हो, इसलिए वे कुछ सौ मीटर तक खिसक सकती हैं", "notice.geo_gpx_lossy": "GPX में केवल पॉइंट, रूट और ट्रैक कुछ फ़ील्ड के साथ रहते हैं: बाकी एट्रिब्यूट छोड़ दिए गए और पॉलीगॉन ट्रैक बन गए", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index afc5f5d33..677cbe0fc 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -99,6 +99,7 @@ "error.geo_unsupported_shape": "この種類の図形には対応していません", "error.geo_too_large": "Shapefile形式の上限(2 GB)を超えてしまいます", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", + "notice.empty_spreadsheet_pages_removed": "スプレッドシートの空白ページを削除しました", "notice.live_photo_pair": "同じ名前の写真とMOVとして保存しました。両方を一緒に取り込むと(iCloud写真、AirDrop、Macの写真アプリ)Live Photoになります", "notice.geo_datum_assumed": "測地系がWGS 84ではありません。WGS 84とみなして変換したため、位置が最大で数百メートルずれることがあります", "notice.geo_gpx_lossy": "GPXに保存できるのはポイント、ルート、トラックと一部の項目だけです。そのほかの属性は省き、ポリゴンはトラックにしました", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 115bfc7e1..c52178bed 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "지원하지 않는 도형 종류예요", "error.geo_too_large": "Shapefile의 한계인 2GB를 넘어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", + "notice.empty_spreadsheet_pages_removed": "스프레드시트의 빈 페이지를 제거했어요", "notice.live_photo_pair": "같은 이름의 사진과 MOV로 저장했어요. 두 파일을 함께 가져오면(iCloud 사진, AirDrop, Mac의 사진 앱) 라이브 포토가 돼요", "notice.geo_datum_assumed": "측지계가 WGS 84가 아니라서 같은 것으로 보고 변환했어요. 위치가 수백 m까지 어긋날 수 있어요", "notice.geo_gpx_lossy": "GPX는 점·경로·트랙과 몇 가지 항목만 담아요. 다른 속성은 빠졌고 면은 트랙으로 바뀌었어요", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index 2e2da36b6..7d5bc9db4 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "Ten rodzaj kształtu nie jest obsługiwany", "error.geo_too_large": "Shapefile przekroczyłby limit 2 GB tego formatu", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", + "notice.empty_spreadsheet_pages_removed": "Usunięto puste strony arkusza kalkulacyjnego", "notice.live_photo_pair": "Zapisano jako zdjęcie i plik MOV o tej samej nazwie. Zaimportuj je razem (Zdjęcia iCloud, AirDrop lub Zdjęcia na Macu), aby otrzymać Live Photo", "notice.geo_datum_assumed": "Datum nie jest WGS 84; pozycje przeliczono tak, jakby było, więc mogą się różnić nawet o setki metrów", "notice.geo_gpx_lossy": "GPX przechowuje tylko punkty, trasy i ślady z kilkoma polami: pozostałe atrybuty pominięto, a poligony stały się śladami", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 0b7cd7cd0..89b950906 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "Este tipo de forma não é compatível", "error.geo_too_large": "O Shapefile passaria do limite de 2 GB do formato", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", + "notice.empty_spreadsheet_pages_removed": "As páginas vazias da planilha foram removidas", "notice.live_photo_pair": "Salvo como uma foto e um MOV com o mesmo nome. Importe os dois juntos (Fotos do iCloud, AirDrop ou Fotos no Mac) para obter uma Live Photo", "notice.geo_datum_assumed": "O datum não é WGS 84; as posições foram convertidas como se fosse, e podem errar em até centenas de metros", "notice.geo_gpx_lossy": "O GPX guarda só pontos, rotas e trilhas com poucos campos: outros atributos ficaram de fora e os polígonos viraram trilhas", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 6247a2b0a..3713222dd 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "不支持此类形状", "error.geo_too_large": "将超过 Shapefile 2 GB 的上限", "notice.pdf_not_smaller": "无法再缩小,已原样复制", + "notice.empty_spreadsheet_pages_removed": "已移除电子表格中的空白页面", "notice.live_photo_pair": "已保存为同名的照片和 MOV。将两者一起导入(iCloud 照片、隔空投送或 Mac 上的“照片”)即可得到实况照片", "notice.geo_datum_assumed": "基准面不是 WGS 84,已按相同处理,位置可能偏差数百米", "notice.geo_gpx_lossy": "GPX 只能保存点、路线和轨迹及少数字段,其他属性已省略,面变成了轨迹", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index d1a197a11..7e44f1d01 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -105,6 +105,7 @@ "error.geo_unsupported_shape": "不支援此類形狀", "error.geo_too_large": "將超過 Shapefile 2 GB 的上限", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", + "notice.empty_spreadsheet_pages_removed": "已移除試算表中的空白頁面", "notice.live_photo_pair": "已儲存為同名的照片和 MOV。將兩者一起匯入(iCloud 照片、隔空投送或 Mac 上的「照片」)即可得到原況照片", "notice.geo_datum_assumed": "基準面不是 WGS 84,已按相同處理,位置可能偏差數百公尺", "notice.geo_gpx_lossy": "GPX 只能儲存點、路線和軌跡及少數字段,其他屬性已省略,面變成了軌跡", From 29072d5d27fcb44e20a02bcde01352c3a1a7d114 Mon Sep 17 00:00:00 2001 From: Perricheno Date: Thu, 22 Oct 2026 16:40:50 +0500 Subject: [PATCH 3/4] Preserve PDF/A metadata in Calc exports --- .../Office/LibreOfficeConverter.cs | 37 +++++++++++------- .../Filee.Engines.Tests/PdfBlankPagesTests.cs | 39 +++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/Filee.Engines/Office/LibreOfficeConverter.cs b/src/Filee.Engines/Office/LibreOfficeConverter.cs index 3cda80106..05fe4b3fd 100644 --- a/src/Filee.Engines/Office/LibreOfficeConverter.cs +++ b/src/Filee.Engines/Office/LibreOfficeConverter.cs @@ -123,25 +123,23 @@ public async Task> ConvertAsync(ConversionStep step, IProg throw new InvalidOperationException( $"LibreOffice produced no output (exit {result.ExitCode}). {result.StandardError.Trim()}"); - // Calc sometimes turns stale Excel print tiles into completely empty trailing PDF pages. Only remove pages - // with no painting operation at all; pages containing text, borders, charts, pictures or annotations stay. - if (step.To == "pdf" && FamilyOf(step.From, FormatRegistry.ExtensionOf(step.InputPath)) == "calc") + // Calc sometimes turns stale Excel print tiles into completely empty PDF pages anywhere in the output. + // Only remove pages with no painting operation at all; pages containing text, borders, charts, pictures + // or annotations stay. PDF/A is never rewritten: doing so would invalidate LibreOffice's XMP metadata. + try { - try - { - var removed = PdfBlankPages.Remove(produced); - if (removed > 0) - { - _logger.LogInformation("{File}: removed {Count} empty spreadsheet PDF pages", step.InputPath, removed); - step.Notice?.Invoke("notice.empty_spreadsheet_pages_removed"); - } - } - catch (Exception e) when (e is not OperationCanceledException) + var removed = RemoveEmptySpreadsheetPages(step, produced); + if (removed > 0) { - // Post-processing is an improvement, not a reason to discard LibreOffice's otherwise valid PDF. - _logger.LogInformation("{File}: could not check empty spreadsheet PDF pages: {Message}", step.InputPath, e.Message); + _logger.LogInformation("{File}: removed {Count} empty spreadsheet PDF pages", step.InputPath, removed); + step.Notice?.Invoke("notice.empty_spreadsheet_pages_removed"); } } + catch (Exception e) when (e is not OperationCanceledException) + { + // Post-processing is an improvement, not a reason to discard LibreOffice's otherwise valid PDF. + _logger.LogInformation("{File}: could not check empty spreadsheet PDF pages: {Message}", step.InputPath, e.Message); + } var target = step.Output.Allocate(step.To); if (target is null) @@ -156,6 +154,15 @@ public async Task> ConvertAsync(ConversionStep step, IProg } } + /// + /// Removes empty Calc PDF pages when that post-processing is safe. PDF/A stays byte-for-byte as LibreOffice wrote + /// it because PDFsharp changes the Info dictionary without updating the archival XMP metadata. + /// + internal static int RemoveEmptySpreadsheetPages(ConversionStep step, string path) => + step.To == "pdf" && !step.Preset.Document.PdfA + && FamilyOf(step.From, FormatRegistry.ExtensionOf(step.InputPath)) == "calc" + ? PdfBlankPages.Remove(path) : 0; + /// Runs LibreOffice into a fresh folder; returns the file it wrote (null if none) and how it ended. private async Task<(string? Produced, ProcessResult Result)> ConvertOnceAsync(string soffice, int slot, string filter, string workDirectory, string input, IProgress? progress, CancellationToken cancellationToken) diff --git a/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs b/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs index 1efd09203..e06ffce9e 100644 --- a/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs +++ b/tests/Filee.Engines.Tests/PdfBlankPagesTests.cs @@ -1,3 +1,6 @@ +using Filee.Core.Conversion; +using Filee.Core.Presets; +using Filee.Engines.Office; using Filee.Engines.Pdf; using PdfSharp.Drawing; using PdfSharp.Pdf; @@ -60,4 +63,40 @@ public void Keeps_a_wholly_blank_document() File.Delete(path); } } + + [Fact] + public void Keeps_PdfA_spreadsheet_output_byte_for_byte() + { + var folder = Directory.CreateTempSubdirectory("filee-pdfa-pages-").FullName; + var path = Path.Combine(folder, "archive.pdf"); + try + { + using (var document = new PdfDocument()) + { + document.AddPage(); // would be removed from an ordinary Calc PDF + var painted = document.AddPage(); + using var graphics = XGraphics.FromPdfPage(painted); + graphics.DrawLine(XPens.Black, 10, 10, 100, 100); + document.Save(path); + } + var before = File.ReadAllBytes(path); + var preset = new Preset { TargetFormat = "pdf", Document = { PdfA = true } }; + var step = new ConversionStep(Path.Combine(folder, "sheet.xlsx"), "xlsx", "pdf", preset, + new FixedOutput(path), folder); + + Assert.Equal(0, LibreOfficeConverter.RemoveEmptySpreadsheetPages(step, path)); + Assert.Equal(before, File.ReadAllBytes(path)); + using var result = PdfReader.Open(path, PdfDocumentOpenMode.Import); + Assert.Equal(2, result.PageCount); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + private sealed class FixedOutput(string path) : IOutputAllocator + { + public string? Allocate(string extension, string? suffix = null) => path; + } } From 9369f22377e84f3ebbc6cb776b11af03279e9328 Mon Sep 17 00:00:00 2001 From: KnifeLemon Date: Fri, 23 Oct 2026 09:33:07 +0900 Subject: [PATCH 4/4] Merge main (Nepali) and translate the empty-pages notice into Nepali Co-Authored-By: Claude Opus 5.5 --- src/Filee.App/Assets/i18n/ne.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index c1b2578ab..2944a9744 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -103,6 +103,7 @@ "error.geo_unsupported_shape": "यस प्रकारको आकार समर्थित छैन", "error.geo_too_large": "Shapefile ढाँचाको २ GB सीमा नाघ्नेछ", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", + "notice.empty_spreadsheet_pages_removed": "स्प्रेडसिटका खाली पृष्ठहरू हटाइए", "notice.live_photo_pair": "एउटै नामको फोटो र MOV का रूपमा सुरक्षित गरियो। लाइभ फोटो पाउन दुवैलाई सँगै आयात गर्नुहोस् (iCloud Photos, AirDrop वा Mac मा Photos)", "notice.geo_datum_assumed": "डेटम WGS 84 होइन; स्थितिहरू यो WGS 84 भए जस्तै रूपान्तरण गरिए, जसले केही सय मिटरसम्म फरक पार्न सक्छ", "notice.geo_gpx_lossy": "GPX मा केही फिल्डसहित केवल पोइन्ट, रुट र ट्र्याक मात्र रहन्छन्: अन्य विशेषताहरू छोडियो र पोलिगनहरू ट्र्याक बने",