Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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 بالنقاط والمسارات والطرق مع حقول محدودة فقط: حُذفت السمات الأخرى وتحولت المضلعات إلى مسارات",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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 में केवल पॉइंट, रूट और ट्रैक कुछ फ़ील्ड के साथ रहते हैं: बाकी एट्रिब्यूट छोड़ दिए गए और पॉलीगॉन ट्रैक बन गए",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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に保存できるのはポイント、ルート、トラックと一部の項目だけです。そのほかの属性は省き、ポリゴンはトラックにしました",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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는 점·경로·트랙과 몇 가지 항목만 담아요. 다른 속성은 빠졌고 면은 트랙으로 바뀌었어요",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/ne.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 मा केही फिल्डसहित केवल पोइन्ट, रुट र ट्र्याक मात्र रहन्छन्: अन्य विशेषताहरू छोडियो र पोलिगनहरू ट्र्याक बने",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 хранит только точки, маршруты и треки с небольшим набором полей: остальные атрибуты не вошли, а полигоны стали треками",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 只能保存点、路线和轨迹及少数字段,其他属性已省略,面变成了轨迹",
Expand Down
1 change: 1 addition & 0 deletions src/Filee.App/Assets/i18n/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,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 只能儲存點、路線和軌跡及少數字段,其他屬性已省略,面變成了軌跡",
Expand Down
28 changes: 28 additions & 0 deletions src/Filee.Engines/Office/LibreOfficeConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -127,6 +128,24 @@ public async Task<IReadOnlyList<string>> 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 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
{
var removed = RemoveEmptySpreadsheetPages(step, 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 [];
Expand All @@ -140,6 +159,15 @@ public async Task<IReadOnlyList<string>> ConvertAsync(ConversionStep step, IProg
}
}

/// <summary>
/// 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.
/// </summary>
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;

/// <summary>Runs LibreOffice into a fresh folder; returns the file it wrote (null if none) and how it ended.</summary>
private async Task<(string? Produced, ProcessResult Result)> ConvertOnceAsync(string soffice, int slot, string filter, string workDirectory,
string input, IProgress<double>? progress, CancellationToken cancellationToken)
Expand Down
85 changes: 85 additions & 0 deletions src/Filee.Engines/Pdf/PdfBlankPages.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Operators that can put a visible mark on a page.</summary>
private static readonly HashSet<OpCodeName> 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,
];

/// <summary>
/// Removes pages with no painting operators from <paramref name="path"/>, 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.
/// </summary>
/// <returns>The number of pages removed.</returns>
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) { }
}
}

/// <summary>Unreadable content is not blank: uncertainty must never delete a user's page.</summary>
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;
}
}
Loading
Loading