diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml deleted file mode 100644 index caa5c0ca3..000000000 --- a/.github/workflows/fuzz.yml +++ /dev/null @@ -1,58 +0,0 @@ -# Nightly mutation fuzzing of the document readers (tests/Filee.Engines.Tests/DocumentFuzzTests.cs): each reader gets a -# few minutes of inputs changed from seeds written in code, with a new random seed every night. Pull requests run the -# same tests briefly with a fixed seed as part of the normal test run; this job only adds the long run. A failure -# names the seed (FILEE_FUZZ_SEED repeats it) and uploads the inputs that failed. -name: Fuzz - -on: - schedule: - - cron: "17 18 * * *" # 03:17 in Korea - workflow_dispatch: - inputs: - seconds: - description: Seconds per reader - default: "180" - seed: - description: Seed to repeat (empty for a new one) - default: "" - -permissions: - contents: read - -concurrency: - group: fuzz - cancel-in-progress: true - -jobs: - fuzz: - runs-on: ubuntu-24.04 - timeout-minutes: 25 - steps: - - uses: actions/checkout@v7 - - - uses: actions/setup-dotnet@v6 - with: - global-json-file: global.json - - - name: Build - run: dotnet build tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj -c Release - - - name: Fuzz the document readers - env: - FILEE_FUZZ_SECONDS: ${{ inputs.seconds || '180' }} - FILEE_FUZZ_SEED: ${{ inputs.seed }} - FILEE_FUZZ_OUT: ${{ runner.temp }}/fuzz-failures - run: | - mkdir -p "$FILEE_FUZZ_OUT" - dotnet test tests/Filee.Engines.Tests/Filee.Engines.Tests.csproj --no-build -c Release --filter "Category=Fuzz" \ - --blame-crash --results-directory TestResults --logger "console;verbosity=detailed" - - - name: Upload the inputs that failed - if: failure() - uses: actions/upload-artifact@v7 - with: - name: fuzz-failures - path: | - ${{ runner.temp }}/fuzz-failures/ - TestResults/ - if-no-files-found: ignore diff --git a/docs/ENGINES.md b/docs/ENGINES.md index bf918f9f8..ed9959e57 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -342,13 +342,7 @@ nested ``, CSS and text are resolved the same way. ## HWPX writer Filee writes OWPML (HWPX) itself, so saving as HWPX needs no Hancom Office and shows no approval dialog. -Readers turn the source into a small document model (`Hwp/Hwpx/HwpxModel.cs`) and `HwpxWriter` writes the package. -The model also keeps what a renderer needs and the writers ignore (`Hwp/Hwpx/HwpxModel.Layout.cs`): line rules with -the source's meaning (Word's multiple and document grid, 한글's "between lines"), 한글's seven language slots and Word's -four run fonts, paragraph borders and line breaking, stored line layouts, crops, rotation, z-order, master pages, -polygons, curves and arcs (`HwpxReader.ReadForLayout` also keeps runs apart by that detail and reads master pages and -those shapes, which `Read` leaves out as before). `StyleResolver` turns what a document leaves to the -한글 style into concrete values. What the readers take over: +Readers turn the source into a small document model (`Hwp/Hwpx/HwpxModel.cs`) and `HwpxWriter` writes the package: - **DOCX** is read directly (`Hwp/Hwpx/Docx/DocxReader*.cs`) so the layout survives: - page size, orientation and margins per section; columns, also when they change mid-page; @@ -628,8 +622,7 @@ sits on the office donut (settings schema 27 teaches existing donuts `.ofd`). resource files (PublicRes, DocumentRes, a page's PageRes, and the unnamed `Res.xml` WPS writes next to them). Parts are looked up by name inside the ZIP and never written out under their own names; references are resolved against the referring part (or the package root for "/…"), "." and ".." applied, and anything that leaves the - package, a URL or a drive path finds nothing. The package is read like HWPX and DOCX (`Documents/DocumentPackage.cs`, - see [HWPX reader](#hwpx-reader)): XML without DTDs; a part may unpack to 64 MB, a picture to + package, a URL or a drive path finds nothing. XML is read without DTDs; a part may unpack to 64 MB, a picture to 32 MB (larger ones are left out), the whole package to 1 GB; at most 100,000 parts, 10,000 pages, 2,000,000 drawing objects, page blocks nested 32 deep. DeltaX / DeltaY "g n d" repeats never yield more values than the text has characters. @@ -1235,31 +1228,6 @@ OLE objects, charts, video, form controls, text art (its text is kept), arcs / p master pages, memos, character ratio, relative size and offset, paragraph borders, picture cropping and rotation. Password-protected (encrypted) HWPX and DRM-wrapped files stop with a clear error. -**Limits.** Untrusted documents must not hang, exhaust the memory or crash Filee. The HWPX, DOCX and OFD readers (and -the HWP 5.0 reader and the layout engine when they come) share `Documents/DocumentLimits.cs`; a document that passes -one stops with `error.document_too_complex` and says which: - -- the package (`Documents/DocumentPackage.cs`): at most 100,000 parts; a part may unpack to 64 MB, all parts together - to 1 GB, counted while they unpack (a ZIP header can understate a size), and a part over 4 MB to no more than 200 - times its packed size; parts are found by name inside the package only, and extracted pictures get names Filee - makes (`hwpx-media-1.png`), never the part's; -- XML (`Infrastructure/SafeXml.cs`, also used for KML and GPX): no DTD and no resolver, so no entity expands and - nothing outside the package is read; at most 512 levels deep and 5,000,000 elements in a package; -- the document (`Documents/DocumentBudget.cs`): tables in tables, text boxes, notes, groups and fields nest at most 32 - levels; at most 1,000,000 paragraphs and 200,000 tables, pictures and drawing objects; table columns, spans and - row spans are cut to the table (at most 16,384 columns); Word style chains are followed 20 styles deep; -- pictures (`Documents/PictureSize.cs`): one whose PNG, JPEG, GIF, BMP or WebP header claims more than - 16,384 × 16,384 pixels is left out before anything decodes it; -- layout (`Documents/LayoutGuard.cs`, for the layout engine): at most 20,000 pages, 64 empty pages in a row, ten minutes; - cancellation and the time are checked per paragraph, and an object larger than the page is clipped and placed. - -Embedded fonts (DOCX `word/fonts`, HWPX and OFD font files) are not read; a reader that uses them must pass them -through Filee's SFNT parser (`Fonts/SfntFont.cs`) first. `DocumentHostileInputTests` holds ZIP bombs, deep nesting, -DTDs and huge pictures; `DocumentFuzzTests` changes packages written in code at random (attributes set to extreme -values, elements removed, repeated, renamed or nested, text and bytes damaged) and allows a reader only to read a -file or refuse it with `InvalidDataException` / `ConversionRefusedException`. It runs briefly with every test run and -for a few minutes per reader every night (`.github/workflows/fuzz.yml`); `FILEE_FUZZ_SEED` repeats a run. - ## E-books The built-in e-book engine (`src/Filee.Engines/Ebooks`) reads books into the same document model as the HWPX diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json index 384a2e147..2e42b0672 100644 --- a/src/Filee.App/Assets/i18n/ar.json +++ b/src/Filee.App/Assets/i18n/ar.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "هذا النوع من خطوط ‎.fon غير مدعوم", "error.fon_too_large": "خط ‎.fon كبير جدًا بحيث لا يمكن تحويله", "error.fon_damaged": "ملف ‎.fon تالف أو غير مكتمل", - "error.document_too_complex": "المستند كبير جدًا أو معقّد جدًا بحيث لا يمكن قراءته بأمان", "error.m4s_needs_init": "ملف ‎.m4s هذا جزء من فيديو متدفّق، ولا يمكن قراءته دون مقطع البداية (init) المنفصل الخاص به", "notice.pdf_not_smaller": "تعذّر تقليل الحجم، لذا نُسخ الملف كما هو", "notice.cad_newer_left_out": "حُذفت كائنات لا يدعمها إصدار AutoCAD المختار (أسهم توضيحية متعددة، شبكات …)", diff --git a/src/Filee.App/Assets/i18n/de.json b/src/Filee.App/Assets/i18n/de.json index 6b0f62eac..9ff6b6d66 100644 --- a/src/Filee.App/Assets/i18n/de.json +++ b/src/Filee.App/Assets/i18n/de.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Diese Art von .fon-Schrift wird nicht unterstützt", "error.fon_too_large": "Die .fon-Schrift ist zu groß zum Konvertieren", "error.fon_damaged": "Die .fon-Datei ist beschädigt oder unvollständig", - "error.document_too_complex": "Das Dokument ist zu groß oder zu komplex, um es sicher zu lesen", "error.m4s_needs_init": "Diese .m4s-Datei ist ein Stück eines gestreamten Videos und lässt sich ohne ihr separates Startsegment (init) nicht lesen", "notice.pdf_not_smaller": "Die Datei ließ sich nicht verkleinern und wurde unverändert kopiert", "notice.cad_newer_left_out": "Objekte, die die gewählte AutoCAD-Version nicht unterstützt (Multiführungslinien, Netze …), wurden weggelassen", diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json index bf28b748e..5d09bbf75 100644 --- a/src/Filee.App/Assets/i18n/en.json +++ b/src/Filee.App/Assets/i18n/en.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "This kind of .fon font isn't supported", "error.fon_too_large": "The .fon font is too large to convert", "error.fon_damaged": "The .fon file is damaged or cut short", - "error.document_too_complex": "The document is too large or too complex to read safely", "error.m4s_needs_init": "This .m4s is one piece of a streamed video: it can't be read without its separate start (init) segment", "notice.pdf_not_smaller": "Couldn't make it smaller, so it was copied as it is", "notice.cad_newer_left_out": "Objects the chosen AutoCAD version doesn't have (multileaders, meshes …) were left out", diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json index 7ef306f53..048ceac5d 100644 --- a/src/Filee.App/Assets/i18n/es.json +++ b/src/Filee.App/Assets/i18n/es.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Este tipo de fuente .fon no es compatible", "error.fon_too_large": "La fuente .fon es demasiado grande para convertirla", "error.fon_damaged": "El archivo .fon está dañado o incompleto", - "error.document_too_complex": "El documento es demasiado grande o complejo para leerlo de forma segura", "error.m4s_needs_init": "Este .m4s es un fragmento de un vídeo en streaming y no se puede leer sin su segmento de inicio (init) aparte", "notice.pdf_not_smaller": "No se pudo reducir su tamaño, por lo que se copió tal cual", "notice.cad_newer_left_out": "Se omitieron objetos que la versión de AutoCAD elegida no tiene (directrices múltiples, mallas…)", diff --git a/src/Filee.App/Assets/i18n/fr.json b/src/Filee.App/Assets/i18n/fr.json index f329c4f4b..7b517140f 100644 --- a/src/Filee.App/Assets/i18n/fr.json +++ b/src/Filee.App/Assets/i18n/fr.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Ce type de police .fon n'est pas pris en charge", "error.fon_too_large": "La police .fon est trop grande pour être convertie", "error.fon_damaged": "Le fichier .fon est endommagé ou incomplet", - "error.document_too_complex": "Le document est trop volumineux ou trop complexe pour être lu en toute sécurité", "error.m4s_needs_init": "Ce .m4s est un morceau d'une vidéo diffusée en streaming : il ne peut pas être lu sans son segment de départ (init) séparé", "notice.pdf_not_smaller": "Impossible de réduire sa taille : le fichier a été copié tel quel", "notice.cad_newer_left_out": "Des objets que la version d'AutoCAD choisie n'a pas (multilignes de repère, maillages…) ont été omis", diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json index 6d87f7cb2..e44149bfe 100644 --- a/src/Filee.App/Assets/i18n/hi.json +++ b/src/Filee.App/Assets/i18n/hi.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "इस तरह का .fon फ़ॉन्ट समर्थित नहीं है", "error.fon_too_large": ".fon फ़ॉन्ट बदलने के लिए बहुत बड़ा है", "error.fon_damaged": ".fon फ़ाइल खराब है या अधूरी है", - "error.document_too_complex": "दस्तावेज़ इतना बड़ा या जटिल है कि उसे सुरक्षित रूप से पढ़ा नहीं जा सकता", "error.m4s_needs_init": "यह .m4s स्ट्रीम किए गए वीडियो का एक हिस्सा है: इसके अलग शुरुआती (init) सेगमेंट के बिना इसे पढ़ा नहीं जा सकता", "notice.pdf_not_smaller": "इसे छोटा नहीं किया जा सका, इसलिए जैसा है वैसा ही कॉपी कर दिया गया", "notice.cad_newer_left_out": "चुने गए AutoCAD संस्करण में न होने वाले ऑब्जेक्ट (मल्टीलीडर, मेश …) छोड़ दिए गए", diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json index 547b261ba..cc0d2b3e3 100644 --- a/src/Filee.App/Assets/i18n/ja.json +++ b/src/Filee.App/Assets/i18n/ja.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "この種類の.fonフォントには対応していません", "error.fon_too_large": ".fonフォントが大きすぎて変換できません", "error.fon_damaged": ".fonファイルが破損しているか、途中で切れています", - "error.document_too_complex": "文書が大きすぎるか複雑すぎるため、安全に読み込めません", "error.m4s_needs_init": "この .m4s はストリーミング動画の一部で、別にある開始(init)セグメントがないと読み込めません", "notice.pdf_not_smaller": "サイズを小さくできなかったため、元のままコピーしました", "notice.cad_newer_left_out": "選んだ AutoCAD のバージョンにないオブジェクト(マルチ引出線、メッシュ …)を省きました", diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json index 2098d6a6e..d5f1e16e4 100644 --- a/src/Filee.App/Assets/i18n/ko.json +++ b/src/Filee.App/Assets/i18n/ko.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "지원하지 않는 종류의 .fon 글꼴이에요", "error.fon_too_large": ".fon 글꼴이 너무 커서 변환할 수 없어요", "error.fon_damaged": ".fon 파일이 손상되었거나 잘렸어요", - "error.document_too_complex": "문서가 너무 크거나 복잡해서 안전하게 읽을 수 없어요", "error.m4s_needs_init": "스트리밍 동영상의 한 조각인 .m4s 파일이에요. 따로 있는 시작(init) 세그먼트 없이는 읽을 수 없어요", "notice.pdf_not_smaller": "더 작게 만들 수 없어서 그대로 복사했어요", "notice.cad_newer_left_out": "선택한 AutoCAD 버전에 없는 개체(다중 지시선, 메시 …)는 빼고 저장했어요", diff --git a/src/Filee.App/Assets/i18n/ne.json b/src/Filee.App/Assets/i18n/ne.json index 0a38f331d..5e8926eee 100644 --- a/src/Filee.App/Assets/i18n/ne.json +++ b/src/Filee.App/Assets/i18n/ne.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "यस प्रकारको .fon फन्ट समर्थित छैन", "error.fon_too_large": ".fon फन्ट रूपान्तरण गर्न धेरै ठूलो छ", "error.fon_damaged": ".fon फाइल बिग्रिएको वा अपूर्ण छ", - "error.document_too_complex": "कागजात सुरक्षित रूपमा पढ्न नसकिने गरी धेरै ठूलो वा जटिल छ", "error.m4s_needs_init": "यो .m4s स्ट्रिम गरिएको भिडियोको एउटा टुक्रा हो: छुट्टै सुरुवाती (init) खण्डबिना यसलाई पढ्न सकिँदैन", "notice.pdf_not_smaller": "यसलाई सानो बनाउन सकिएन, त्यसैले जस्तो छ त्यस्तै प्रतिलिपि गरियो", "notice.cad_newer_left_out": "छानिएको AutoCAD संस्करणमा नभएका वस्तुहरू (मल्टिलिडर, मेस …) छोडिए", diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json index c4e18c3c5..8f50edccb 100644 --- a/src/Filee.App/Assets/i18n/pl.json +++ b/src/Filee.App/Assets/i18n/pl.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Ten rodzaj czcionki .fon nie jest obsługiwany", "error.fon_too_large": "Czcionka .fon jest zbyt duża, aby ją przekonwertować", "error.fon_damaged": "Plik .fon jest uszkodzony lub niekompletny", - "error.document_too_complex": "Dokument jest zbyt duży lub zbyt złożony, aby bezpiecznie go odczytać", "error.m4s_needs_init": "Ten plik .m4s to fragment wideo strumieniowego i nie da się go odczytać bez osobnego segmentu początkowego (init)", "notice.pdf_not_smaller": "Nie udało się zmniejszyć pliku, więc skopiowano go bez zmian", "notice.cad_newer_left_out": "Pominięto obiekty, których wybrana wersja AutoCAD nie ma (multiodnośniki, siatki…)", diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json index 96fefb715..9b96f02f8 100644 --- a/src/Filee.App/Assets/i18n/pt-BR.json +++ b/src/Filee.App/Assets/i18n/pt-BR.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é compatível", "error.fon_too_large": "A fonte .fon é grande demais para converter", "error.fon_damaged": "O arquivo .fon está danificado ou incompleto", - "error.document_too_complex": "O documento é grande ou complexo demais para ser lido com segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo transmitido por streaming e não pode ser lido sem o seu segmento inicial (init) separado", "notice.pdf_not_smaller": "Não deu para deixar menor, então foi copiado como está", "notice.cad_newer_left_out": "Objetos que a versão do AutoCAD escolhida não tem (multilinhas de chamada, malhas…) ficaram de fora", diff --git a/src/Filee.App/Assets/i18n/pt-PT.json b/src/Filee.App/Assets/i18n/pt-PT.json index d9784665d..41c27c756 100644 --- a/src/Filee.App/Assets/i18n/pt-PT.json +++ b/src/Filee.App/Assets/i18n/pt-PT.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Este tipo de fonte .fon não é suportado", "error.fon_too_large": "A fonte .fon é demasiado grande para converter", "error.fon_damaged": "O ficheiro .fon está danificado ou incompleto", - "error.document_too_complex": "O documento é demasiado grande ou complexo para ser lido em segurança", "error.m4s_needs_init": "Este .m4s é um pedaço de um vídeo em streaming e não pode ser lido sem o respetivo segmento inicial (init) separado", "notice.pdf_not_smaller": "Não foi possível reduzir o tamanho, pelo que o ficheiro foi copiado sem alterações", "notice.cad_newer_left_out": "Os objetos que a versão de AutoCAD escolhida não suporta (linhas de chamada múltiplas, malhas…) foram omitidos", diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json index e7548813f..e1a24eb79 100644 --- a/src/Filee.App/Assets/i18n/ru.json +++ b/src/Filee.App/Assets/i18n/ru.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "Этот тип шрифта .fon не поддерживается", "error.fon_too_large": "Шрифт .fon слишком велик для преобразования", "error.fon_damaged": "Файл .fon повреждён или обрезан", - "error.document_too_complex": "Документ слишком большой или сложный, чтобы безопасно его прочитать", "error.m4s_needs_init": "Этот .m4s — фрагмент потокового видео: без отдельного начального сегмента (init) его не прочитать", "notice.pdf_not_smaller": "Уменьшить не получилось, файл скопирован как есть", "notice.cad_newer_left_out": "Объекты, которых нет в выбранной версии AutoCAD (мультивыноски, сети…), не сохранены", diff --git a/src/Filee.App/Assets/i18n/th.json b/src/Filee.App/Assets/i18n/th.json index 12e899c40..536d044e9 100644 --- a/src/Filee.App/Assets/i18n/th.json +++ b/src/Filee.App/Assets/i18n/th.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "ไม่รองรับฟอนต์ .fon ประเภทนี้", "error.fon_too_large": "ฟอนต์ .fon มีขนาดใหญ่เกินกว่าจะแปลงได้", "error.fon_damaged": "ไฟล์ .fon เสียหายหรือไม่สมบูรณ์", - "error.document_too_complex": "เอกสารใหญ่หรือซับซ้อนเกินกว่าจะอ่านได้อย่างปลอดภัย", "error.m4s_needs_init": "ไฟล์ .m4s นี้เป็นชิ้นส่วนหนึ่งของวิดีโอสตรีมมิง และอ่านไม่ได้หากไม่มีเซกเมนต์เริ่มต้น (init) ที่แยกไว้", "notice.pdf_not_smaller": "ไม่สามารถบีบอัดให้เล็กลงได้ จึงคัดลอกไฟล์ตามเดิม", "notice.cad_newer_left_out": "ละวัตถุที่ AutoCAD เวอร์ชันที่เลือกไม่มี (มัลติลีดเดอร์ เมช …) ไว้", diff --git a/src/Filee.App/Assets/i18n/tr.json b/src/Filee.App/Assets/i18n/tr.json index 96f6dc4e8..449f8b300 100644 --- a/src/Filee.App/Assets/i18n/tr.json +++ b/src/Filee.App/Assets/i18n/tr.json @@ -130,7 +130,6 @@ "error.fon_unsupported": "Bu tür .fon yazı tipi desteklenmiyor", "error.fon_too_large": ".fon yazı tipi dönüştürmek için çok büyük", "error.fon_damaged": ".fon dosyası hasarlı veya eksik", - "error.document_too_complex": "Belge güvenli şekilde okunamayacak kadar büyük veya karmaşık", "error.m4s_needs_init": "Bu .m4s, akışla gelen bir videonun bir parçası: ayrı başlangıç (init) segmenti olmadan okunamaz", "notice.pdf_not_smaller": "Küçültülemediği için olduğu gibi kopyalandı", "notice.cad_newer_left_out": "Seçilen AutoCAD sürümünün desteklemediği nesneler (çoklu kılavuzlar, ağlar…) çıkarıldı", diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json index 46155fad3..8d069c66b 100644 --- a/src/Filee.App/Assets/i18n/zh-CN.json +++ b/src/Filee.App/Assets/i18n/zh-CN.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "不支持此类 .fon 字体", "error.fon_too_large": ".fon 字体太大,无法转换", "error.fon_damaged": ".fon 文件已损坏或不完整", - "error.document_too_complex": "文档过大或过于复杂,无法安全读取", "error.m4s_needs_init": "此 .m4s 是流媒体视频的一个片段,没有单独的起始(init)分段就无法读取", "notice.pdf_not_smaller": "无法再缩小,已原样复制", "notice.cad_newer_left_out": "已省略所选 AutoCAD 版本没有的对象(多重引线、网格……)", diff --git a/src/Filee.App/Assets/i18n/zh-TW.json b/src/Filee.App/Assets/i18n/zh-TW.json index 3b8018054..5d5551093 100644 --- a/src/Filee.App/Assets/i18n/zh-TW.json +++ b/src/Filee.App/Assets/i18n/zh-TW.json @@ -136,7 +136,6 @@ "error.fon_unsupported": "不支援此類 .fon 字型", "error.fon_too_large": ".fon 字型太大,無法轉換", "error.fon_damaged": ".fon 檔案已損壞或不完整", - "error.document_too_complex": "文件過大或過於複雜,無法安全讀取", "error.m4s_needs_init": "此 .m4s 是串流影片的一個片段,沒有另外的起始(init)區段就無法讀取", "notice.pdf_not_smaller": "無法再縮小,已原樣複製", "notice.cad_newer_left_out": "已略過所選 AutoCAD 版本沒有的物件(複線引線、網格……)", diff --git a/src/Filee.Engines/Documents/DocumentBudget.cs b/src/Filee.Engines/Documents/DocumentBudget.cs deleted file mode 100644 index 631f19cb8..000000000 --- a/src/Filee.Engines/Documents/DocumentBudget.cs +++ /dev/null @@ -1,58 +0,0 @@ -// What one document reader may still build: nesting (tables in tables, text boxes, notes, groups, fields) and the -// number of paragraphs and objects. Readers call it where they recurse or add to the model, so a small file that -// expands into millions of paragraphs, or nests deeply enough to overflow the stack in a writer, is refused. - -namespace Filee.Engines.Documents; - -/// Counts what a reader builds against . -internal sealed class DocumentBudget(DocumentLimits limits) -{ - private int _depth; - private int _paragraphs; - private int _objects; - - public DocumentLimits Limits { get; } = limits; - - /// Current nesting depth. - public int Depth => _depth; - - public int Paragraphs => _paragraphs; - - public int Objects => _objects; - - /// Enters a nested level (a table cell, text box, note, group); dispose the result to leave it. - public Level Enter() - { - if (_depth >= Limits.MaxNestingDepth) - throw DocumentLimits.Exceeded($"Tables, text boxes, notes or groups are nested more than {Limits.MaxNestingDepth} levels deep."); - _depth++; - return new Level(this); - } - - /// Checks a count of open fields (fields inside fields). - public void Fields(int open) - { - if (open > Limits.MaxNestingDepth) - throw DocumentLimits.Exceeded($"Fields are nested more than {Limits.MaxNestingDepth} levels deep."); - } - - /// Counts a paragraph. - public void Paragraph() - { - if (++_paragraphs > Limits.MaxParagraphs) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxParagraphs)} paragraphs."); - } - - /// Counts a table, picture, shape, text box or equation. - public void Object() - { - if (++_objects > Limits.MaxObjects) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(Limits.MaxObjects)} tables, pictures and drawing objects."); - } - - /// A nested level; leaves it when disposed. - public readonly struct Level(DocumentBudget budget) : IDisposable - { - public void Dispose() => budget._depth--; - } -} diff --git a/src/Filee.Engines/Documents/DocumentLimits.cs b/src/Filee.Engines/Documents/DocumentLimits.cs deleted file mode 100644 index 892cf8e07..000000000 --- a/src/Filee.Engines/Documents/DocumentLimits.cs +++ /dev/null @@ -1,96 +0,0 @@ -// How much of an untrusted document Filee reads and lays out, shared by the document readers (HWPX, DOCX, OFD, and -// the HWP 5.0 reader when it comes) and the layout engine, so a hostile file can't hang, exhaust the memory or crash -// Filee. Every limit is far above what real documents need: a document that passes one is refused with -// error.document_too_complex rather than read in part. -// -// Where they apply: -// * DocumentPackage ZIP packages: entries, bytes per part and in total (counted while unpacking, so a header that -// understates a part does not help), the inflate ratio, XML depth and elements (SafeXml). -// * DocumentBudget readers: nesting of tables, text boxes, notes, groups and fields; paragraphs and objects. -// * PictureSize pictures whose header claims more than MaxPicturePixels are left out. -// * LayoutGuard the layout loop: pages, empty pages in a row, the time budget and cancellation. -// A compound file (CFB) reader adds its stream sizes to MaxPartBytes / MaxPackageBytes and must check sector chains -// for cycles itself. -// -// Embedded fonts are not read by any reader (DOCX odttf parts, HWPX and OFD font files keep only their names). A -// reader that ever uses them must pass them through Filee's own SFNT parser (Fonts/SfntFont) before Skia or HarfBuzz. - -using System.Globalization; -using Filee.Core.Conversion; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Documents; - -/// Limits for reading and laying out one document. -internal sealed record DocumentLimits -{ - /// The limits Filee uses. - public static DocumentLimits Default { get; } = new(); - - /// The error key of a document refused for passing a limit. - public const string ErrorKey = "error.document_too_complex"; - - /// Bytes one part of a package (an XML part, a picture) may unpack to. - public long MaxPartBytes { get; init; } = 64L * 1024 * 1024; - - /// Bytes all parts read from one package may unpack to together. - public long MaxPackageBytes { get; init; } = 1024L * 1024 * 1024; - - /// Entries in a package (real documents have a few per page or picture). - public int MaxPackageEntries { get; init; } = 100_000; - - /// - /// How many times its compressed size a part may unpack to, once it is larger than . - /// Deflate reaches about 1,000 on runs of one byte; XML from word processors stays far below 100. - /// - public int MaxInflateRatio { get; init; } = 200; - - /// What a part may always unpack to, whatever its ratio (small parts of repeated markup compress well). - public long InflateRatioFloor { get; init; } = 4L * 1024 * 1024; - - /// Levels of XML elements in a part. Tables nested deep stay well within. - public int MaxXmlDepth { get; init; } = 512; - - /// XML elements in all parts of a package together (each costs some 100 bytes of memory in the tree). - public int MaxXmlElements { get; init; } = 5_000_000; - - /// Levels of tables in tables, text boxes, notes, groups and fields. - public int MaxNestingDepth { get; init; } = 32; - - /// Paragraphs in a document (cells, notes, headers and text boxes included). - public int MaxParagraphs { get; init; } = 1_000_000; - - /// Tables, pictures, shapes, text boxes and equations in a document. - public int MaxObjects { get; init; } = 200_000; - - /// - /// Columns of a table (as many as a spreadsheet has). Column numbers and spans beyond are cut there, so a damaged - /// cell address can't make a table of two billion columns. - /// - public int MaxTableColumns { get; init; } = 16_384; - - /// Pages the layout engine makes of one document. - public int MaxPages { get; init; } = 20_000; - - /// Pixels of one picture (16,384 × 16,384, beyond every camera); bigger pictures are left out. - public long MaxPicturePixels { get; init; } = 16_384L * 16_384; - - /// Pages in a row with nothing placed on them before the layout counts as stuck. - public int MaxConsecutiveEmptyPages { get; init; } = 64; - - /// How long laying out one document may take. - public TimeSpan LayoutTimeBudget { get; init; } = TimeSpan.FromMinutes(10); - - /// The XML limits of one part, given how many elements the package's other parts held. - internal XmlLimits Xml(int elementsBefore, bool preserveWhitespace) => - new(MaxXmlDepth, MaxXmlElements, MaxPartBytes, preserveWhitespace, ElementsBefore: elementsBefore); - - /// The refusal for a document that passes a limit; says which (in English). - public static ConversionRefusedException Exceeded(string detail) => new(ErrorKey, detail); - - /// "64 MB" for messages. - internal static string Megabytes(long bytes) => (bytes / (1024 * 1024)).ToString("N0", CultureInfo.InvariantCulture) + " MB"; - - /// "20,000" for messages. - internal static string Count(long count) => count.ToString("N0", CultureInfo.InvariantCulture); -} diff --git a/src/Filee.Engines/Documents/DocumentPackage.cs b/src/Filee.Engines/Documents/DocumentPackage.cs deleted file mode 100644 index 0cccc7131..000000000 --- a/src/Filee.Engines/Documents/DocumentPackage.cs +++ /dev/null @@ -1,240 +0,0 @@ -// The ZIP package of a document (HWPX, DOCX, OFD), read within DocumentLimits: parts are looked up by name inside the -// package only, every part is counted while it is unpacked (its own size, the package's total and the inflate ratio), -// XML is read by SafeXml without DTDs or a resolver, and parts are only ever written out under names Filee makes. -// A damaged package fails with InvalidDataException, one that passes a limit with error.document_too_complex. - -using System.IO.Compression; -using System.Text; -using System.Xml; -using System.Xml.Linq; -using Filee.Engines.Infrastructure; - -namespace Filee.Engines.Documents; - -/// A document package opened for reading. -internal sealed class DocumentPackage : IDisposable -{ - private readonly ZipArchive _zip; - private long _total; - private int _elements; - - private DocumentPackage(ZipArchive zip, DocumentLimits limits) - { - _zip = zip; - Limits = limits; - } - - public DocumentLimits Limits { get; } - - /// Bytes unpacked so far. - public long BytesRead => _total; - - /// XML elements read so far. - public int ElementsRead => _elements; - - public IReadOnlyCollection Entries => _zip.Entries; - - /// Opens the package at ; makes the error for a file that is no ZIP. - public static DocumentPackage Open(string path, DocumentLimits? limits = null, Func? notAPackage = null) => - Open(() => ZipFile.OpenRead(path), limits ?? DocumentLimits.Default, notAPackage); - - /// A package in a stream (left open). - public static DocumentPackage Open(Stream stream, DocumentLimits? limits = null, Func? notAPackage = null) => - Open(() => new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true), limits ?? DocumentLimits.Default, notAPackage); - - private static DocumentPackage Open(Func open, DocumentLimits limits, Func? notAPackage) - { - ZipArchive zip; - try - { - zip = open(); - } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or NotSupportedException - || ex is IOException and not (FileNotFoundException or DirectoryNotFoundException)) - { - throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); - } - try - { - // The central directory is read when the entries are first listed. - if (zip.Entries.Count > limits.MaxPackageEntries) - throw DocumentLimits.Exceeded($"The package has more than {DocumentLimits.Count(limits.MaxPackageEntries)} parts."); - } - catch (Exception ex) when (ex is InvalidDataException or EndOfStreamException or ArgumentException or IOException) - { - zip.Dispose(); - throw notAPackage?.Invoke() ?? new InvalidDataException("The file is not a ZIP package. It may be damaged.", ex); - } - catch - { - zip.Dispose(); - throw; - } - return new DocumentPackage(zip, limits); - } - - public void Dispose() => _zip.Dispose(); - - /// The entry of that exact name; null when there is none. - public ZipArchiveEntry? GetEntry(string name) - { - try - { - return name.Length == 0 ? null : _zip.GetEntry(name); - } - catch (ArgumentException) - { - return null; - } - } - - /// - /// The unpacked bytes of , counted against the part, package and ratio limits. A part whose - /// header already claims too much is refused before it is unpacked. - /// - public Stream Open(ZipArchiveEntry entry) - { - var name = entry.FullName; - if (entry.Length > Limits.MaxPartBytes) - throw PartTooLarge(name); - if (_total + entry.Length > Limits.MaxPackageBytes) - throw PackageTooLarge(); - Stream inner; - try - { - inner = entry.Open(); - } - catch (Exception ex) when (ex is IOException or NotSupportedException or ArgumentException) - { - throw new InvalidDataException($"The part {name} of the package can't be unpacked ({ex.Message}).", ex); - } - var compressed = Math.Max(1, entry.CompressedLength); - long counted = 0; - return new LimitedStream(new DamageStream(inner, name), Limits.MaxPartBytes, () => PartTooLarge(name), read => - { - _total += read - counted; - counted = read; - if (_total > Limits.MaxPackageBytes) - throw PackageTooLarge(); - if (read > Limits.InflateRatioFloor && read / compressed > Limits.MaxInflateRatio) - throw DocumentLimits.Exceeded($"The part {name} unpacks to more than {Limits.MaxInflateRatio} times its packed size."); - }); - } - - /// - /// An XML part read by ; its elements count against the package's. Throws - /// for a part that is not well-formed XML (callers say which part of which format). - /// - public XDocument LoadXml(ZipArchiveEntry entry, bool preserveWhitespace = true) - { - using var stream = Open(entry); - try - { - var document = SafeXml.Load(stream, Limits.Xml(_elements, preserveWhitespace), - message => DocumentLimits.Exceeded($"{entry.FullName}: {message}"), out var elements); - _elements += elements; - return document; - } - catch (Exception ex) when (ex is DecoderFallbackException or ArgumentException) - { - throw new XmlException(ex.Message, ex); - } - } - - /// The bytes of a part; null when it holds more than (the part is then left out). - public byte[]? ReadBytes(ZipArchiveEntry entry, long max) - { - if (entry.Length > max) - return null; - using var stream = Open(entry); - using var copy = new MemoryStream((int)Math.Min(entry.Length, 1024 * 1024)); - var buffer = new byte[81920]; - int count; - while ((count = stream.Read(buffer)) > 0) - { - if (copy.Length + count > max) - return null; - copy.Write(buffer, 0, count); - } - return copy.ToArray(); - } - - /// - /// Unpacks a picture into a new file at . Returns false (and leaves no file) when its - /// header claims more than . - /// - public bool ExtractPicture(ZipArchiveEntry entry, string path) - { - using (var source = Open(entry)) - using (var target = File.Create(path)) - source.CopyTo(target); - if (PictureSize.Read(path) is { } size && (long)size.Width * size.Height > Limits.MaxPicturePixels) - { - File.Delete(path); - return false; - } - return true; - } - - /// - /// The extension of a part name for a file Filee writes: "." and up to 8 ASCII letters or digits, else "" (a name - /// in a hostile package may hold anything, ":stream" included). - /// - public static string SafeExtension(string name) - { - var dot = name.LastIndexOf('.'); - if (dot < 0 || name.Length - dot - 1 is < 1 or > 8 || name.AsSpan(dot + 1).ContainsAnyExcept(AsciiLettersAndDigits)) - return ""; - return name[dot..].ToLowerInvariant(); - } - - private static readonly System.Buffers.SearchValues AsciiLettersAndDigits = - System.Buffers.SearchValues.Create("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"); - - /// Reads a part; a package cut short or damaged inside a part is invalid data, not a failing disk. - private sealed class DamageStream(Stream inner, string name) : Stream - { - public override bool CanRead => true; - public override bool CanSeek => false; - public override bool CanWrite => false; - public override long Length => throw new NotSupportedException(); - - public override long Position - { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } - - public override int Read(byte[] buffer, int offset, int count) => Read(buffer.AsSpan(offset, count)); - - public override int Read(Span buffer) - { - try - { - return inner.Read(buffer); - } - catch (IOException ex) - { - throw new InvalidDataException($"The part {name} of the package is damaged ({ex.Message}).", ex); - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) - inner.Dispose(); - base.Dispose(disposing); - } - - public override void Flush() { } - public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); - public override void SetLength(long value) => throw new NotSupportedException(); - public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); - } - - private Exception PartTooLarge(string name) => - DocumentLimits.Exceeded($"The part {name} unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPartBytes)}."); - - private Exception PackageTooLarge() => - DocumentLimits.Exceeded($"The package unpacks to more than {DocumentLimits.Megabytes(Limits.MaxPackageBytes)}."); -} diff --git a/src/Filee.Engines/Documents/LayoutGuard.cs b/src/Filee.Engines/Documents/LayoutGuard.cs deleted file mode 100644 index 79c142fac..000000000 --- a/src/Filee.Engines/Documents/LayoutGuard.cs +++ /dev/null @@ -1,71 +0,0 @@ -// Guards for the layout loop, so a document can't keep the layout engine busy forever: every paragraph checks the -// cancellation token and the time budget, every page counts against MaxPages, and pages in a row that receive nothing -// mean the layout is stuck. An object larger than every page is clipped to the area it goes into and placed, so it -// can't push itself from page to page without end. - -using System.Diagnostics; - -namespace Filee.Engines.Documents; - -/// Checks a layout run against . -internal sealed class LayoutGuard -{ - private readonly DocumentLimits _limits; - private readonly CancellationToken _cancellationToken; - private readonly Func _elapsed; - private int _pages; - private int _emptyInARow; - - /// Time since the layout started (tests pass their own clock). - public LayoutGuard(DocumentLimits limits, CancellationToken cancellationToken, Func? elapsed = null) - { - _limits = limits; - _cancellationToken = cancellationToken; - if (elapsed is null) - { - var start = Stopwatch.GetTimestamp(); - elapsed = () => Stopwatch.GetElapsedTime(start); - } - _elapsed = elapsed; - } - - /// Pages finished so far. - public int Pages => _pages; - - /// Called before each paragraph is laid out: throws when the job was cancelled or the time is up. - /// The job was cancelled. - public void Paragraph() - { - _cancellationToken.ThrowIfCancellationRequested(); - if (_elapsed() > _limits.LayoutTimeBudget) - throw DocumentLimits.Exceeded($"Laying out the document took longer than {Describe(_limits.LayoutTimeBudget)}."); - } - - private static string Describe(TimeSpan time) => time.TotalMinutes >= 1 - ? time.TotalMinutes == 1 ? "1 minute" : $"{time.TotalMinutes:0} minutes" - : time.TotalSeconds == 1 ? "1 second" : $"{time.TotalSeconds:0} seconds"; - - /// Called when a page is finished; when nothing was placed on it. - public void PageFinished(bool empty) - { - if (++_pages > _limits.MaxPages) - throw DocumentLimits.Exceeded($"The document has more than {DocumentLimits.Count(_limits.MaxPages)} pages."); - _emptyInARow = empty ? _emptyInARow + 1 : 0; - if (_emptyInARow > _limits.MaxConsecutiveEmptyPages) - throw DocumentLimits.Exceeded($"The layout made more than {_limits.MaxConsecutiveEmptyPages} empty pages in a row."); - } - - /// - /// The size an object is placed with in an area of × - /// (the body of an empty page): its own size when it fits, else clipped to the area. Clipped tells the - /// layout to draw only that part rather than move the object to the next page again. Sizes that are not finite - /// numbers or negative count as 0. - /// - public static (double Width, double Height, bool Clipped) Fit(double width, double height, double areaWidth, double areaHeight) - { - static double Clean(double value) => double.IsFinite(value) && value > 0 ? value : 0; - (width, height, areaWidth, areaHeight) = (Clean(width), Clean(height), Clean(areaWidth), Clean(areaHeight)); - var clipped = width > areaWidth || height > areaHeight; - return (Math.Min(width, areaWidth), Math.Min(height, areaHeight), clipped); - } -} diff --git a/src/Filee.Engines/Documents/PictureSize.cs b/src/Filee.Engines/Documents/PictureSize.cs deleted file mode 100644 index ce944b0a1..000000000 --- a/src/Filee.Engines/Documents/PictureSize.cs +++ /dev/null @@ -1,146 +0,0 @@ -// The pixel size a picture's header claims, read without decoding it, so a picture that would take gigabytes once -// decoded (a few kilobytes of PNG can claim 100,000 × 100,000 pixels) is left out before anything decodes it. PNG, -// JPEG, GIF, BMP and WebP; other formats (TIFF, EMF, WMF, SVG) give no size here and are checked where they are drawn. - -using System.Buffers.Binary; - -namespace Filee.Engines.Documents; - -/// Reads the width and height from a picture's header. -internal static class PictureSize -{ - /// JPEG segments looked at before the frame header (files have a dozen). - private const int MaxJpegSegments = 1000; - - /// The size in pixels; null for formats it does not know and headers it can't read. - public static (int Width, int Height)? Read(string path) - { - try - { - using var stream = File.OpenRead(path); - return Read(stream); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - return null; - } - } - - /// - public static (int Width, int Height)? Read(Stream stream) - { - Span head = stackalloc byte[30]; - var length = ReadUpTo(stream, head); - head = head[..length]; - if (head is [0x89, (byte)'P', (byte)'N', (byte)'G', ..] && head.Length >= 24) - return Size(BinaryPrimitives.ReadUInt32BigEndian(head[16..]), BinaryPrimitives.ReadUInt32BigEndian(head[20..])); - if (head is [(byte)'G', (byte)'I', (byte)'F', (byte)'8', ..] && head.Length >= 10) - return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[6..]), BinaryPrimitives.ReadUInt16LittleEndian(head[8..])); - if (head is [(byte)'B', (byte)'M', ..] && head.Length >= 26) - { - var header = BinaryPrimitives.ReadUInt32LittleEndian(head[14..]); - return header == 12 - ? Size(BinaryPrimitives.ReadUInt16LittleEndian(head[18..]), BinaryPrimitives.ReadUInt16LittleEndian(head[20..])) - : Size((uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[18..])), (uint)Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(head[22..]))); - } - if (head is [(byte)'R', (byte)'I', (byte)'F', (byte)'F', _, _, _, _, (byte)'W', (byte)'E', (byte)'B', (byte)'P', ..] && head.Length >= 30) - return WebP(head); - if (head is [0xFF, 0xD8, ..]) - return Jpeg(stream, head); - return null; - } - - private static (int Width, int Height)? WebP(ReadOnlySpan head) - { - var chunk = head.Slice(12, 4); - if (chunk.SequenceEqual("VP8X"u8)) - { - var width = head[24] | head[25] << 8 | head[26] << 16; - var height = head[27] | head[28] << 8 | head[29] << 16; - return Size((uint)width + 1, (uint)height + 1); - } - if (chunk.SequenceEqual("VP8L"u8) && head[20] == 0x2F) - { - var bits = BinaryPrimitives.ReadUInt32LittleEndian(head[21..]); - return Size((bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1); - } - if (chunk.SequenceEqual("VP8 "u8)) - return Size(BinaryPrimitives.ReadUInt16LittleEndian(head[26..]) & 0x3FFFu, BinaryPrimitives.ReadUInt16LittleEndian(head[28..]) & 0x3FFFu); - return null; - } - - /// The first frame header (SOFn) after the segments before it. - private static (int Width, int Height)? Jpeg(Stream stream, ReadOnlySpan head) - { - // Continue from byte 2 of the file: the head was read already. - var buffered = new Queue(head[2..].ToArray()); - int Next() - { - if (buffered.Count > 0) - return buffered.Dequeue(); - return stream.ReadByte(); - } - - Span frame = stackalloc byte[5]; - for (var segment = 0; segment < MaxJpegSegments; segment++) - { - var marker = Next(); - if (marker < 0) - return null; - if (marker != 0xFF) - return null; - var code = Next(); - while (code == 0xFF) - code = Next(); - if (code < 0 || code is 0xD9 or 0xDA) - return null; // end of image or scan data before any frame header - if (code is 0x01 or (>= 0xD0 and <= 0xD7)) - continue; // markers without a length - var high = Next(); - var low = Next(); - if (high < 0 || low < 0) - return null; - var length = high << 8 | low; - if (length < 2) - return null; - if (code is >= 0xC0 and <= 0xCF and not (0xC4 or 0xC8 or 0xCC)) - { - for (var i = 0; i < frame.Length; i++) - { - var b = Next(); - if (b < 0) - return null; - frame[i] = (byte)b; - } - return Size(BinaryPrimitives.ReadUInt16BigEndian(frame[3..]), BinaryPrimitives.ReadUInt16BigEndian(frame[1..])); - } - var skip = length - 2; - for (; skip > 0 && buffered.Count > 0; skip--) - buffered.Dequeue(); - if (stream.CanSeek) - { - if (stream.Seek(skip, SeekOrigin.Current) > stream.Length) - return null; - continue; - } - for (; skip > 0; skip--) - { - if (Next() < 0) - return null; - } - } - return null; - } - - private static (int Width, int Height)? Size(uint width, uint height) => - width == 0 || height == 0 ? null : ((int)Math.Min(width, int.MaxValue), (int)Math.Min(height, int.MaxValue)); - - private static int ReadUpTo(Stream stream, Span buffer) - { - var total = 0; - int read; - while (total < buffer.Length && (read = stream.Read(buffer[total..])) > 0) - total += read; - return total; - } -} diff --git a/src/Filee.Engines/Geo/KmlFormat.cs b/src/Filee.Engines/Geo/KmlFormat.cs index c22612bdc..c6c35c379 100644 --- a/src/Filee.Engines/Geo/KmlFormat.cs +++ b/src/Filee.Engines/Geo/KmlFormat.cs @@ -11,7 +11,6 @@ using System.Text.Json; using System.Xml; using System.Xml.Linq; -using Filee.Engines.Infrastructure; namespace Filee.Engines.Geo; @@ -410,15 +409,67 @@ internal static class GeoXml /// /// Parses XML without DTDs or external entities, so a hostile file can't read files or expand into gigabytes, and - /// refuses elements nested more than levels (see ). Namespace - /// declarations and prefixes are not kept; names are the local name and namespace. + /// refuses elements nested more than levels. The tree is built here from the reader + /// rather than by XDocument.Load, which takes time quadratic in the depth (a 1.4 MB file nested 100,000 deep + /// kept it busy for 48 seconds, the reader alone needs a quarter of a second) and so couldn't be allowed to see such + /// a file. Namespace declarations and prefixes are not kept; names are the local name and namespace. /// public static XDocument Load(Stream stream, int maxElements = MaxElements) { - try + var settings = new XmlReaderSettings { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null, + MaxCharactersFromEntities = 0, + CheckCharacters = false, // About 1 GB of memory at the most: the tree takes some 16 bytes for every character. - return SafeXml.Load(stream, new XmlLimits(MaxElementDepth, maxElements, 1L << 26, KeepNamespaceDeclarations: false, CheckCharacters: false)); + MaxCharactersInDocument = 1L << 26, + }; + try + { + using var reader = XmlReader.Create(stream, settings); + var document = new XDocument(); + XContainer current = document; + var depth = 0; + var elements = 0; + while (reader.Read()) + { + switch (reader.NodeType) + { + case XmlNodeType.Element: + if (++depth > MaxElementDepth) + throw new InvalidDataException($"The file nests elements more than {MaxElementDepth} levels deep."); + if (++elements > maxElements) + throw new InvalidDataException("The file has more than " + maxElements.ToString("N0", System.Globalization.CultureInfo.InvariantCulture) + " elements."); + var element = new XElement(XName.Get(reader.LocalName, reader.NamespaceURI)); + var empty = reader.IsEmptyElement; + if (reader.HasAttributes) + { + while (reader.MoveToNextAttribute()) + { + if (reader.NamespaceURI != "http://www.w3.org/2000/xmlns/") + element.SetAttributeValue(XName.Get(reader.LocalName, reader.NamespaceURI), reader.Value); + } + reader.MoveToElement(); + } + current.Add(element); + if (empty) + depth--; + else + current = element; + break; + case XmlNodeType.EndElement: + current = current.Parent ?? (XContainer)document; + depth--; + break; + case XmlNodeType.Text or XmlNodeType.CDATA or XmlNodeType.SignificantWhitespace or XmlNodeType.Whitespace: + // Text outside the root element (white space between declarations) is no content. + if (current is XElement) + current.Add(new XText(reader.Value)); + break; + } + } + return document; } catch (XmlException ex) { diff --git a/src/Filee.Engines/Infrastructure/LimitedStream.cs b/src/Filee.Engines/Geo/LimitedStream.cs similarity index 51% rename from src/Filee.Engines/Infrastructure/LimitedStream.cs rename to src/Filee.Engines/Geo/LimitedStream.cs index ed171a016..57e7aa38a 100644 --- a/src/Filee.Engines/Infrastructure/LimitedStream.cs +++ b/src/Filee.Engines/Geo/LimitedStream.cs @@ -1,31 +1,13 @@ // A read-only wrapper that counts the bytes read through it and fails once they pass a limit, so a ZIP entry that // unpacks to far more than its header says (a ZIP bomb) stops at a known size instead of filling the memory. -namespace Filee.Engines.Infrastructure; +namespace Filee.Engines.Geo; -/// Reads at most limit bytes of inner, then throws ( by default). -internal sealed class LimitedStream : Stream +/// Reads at most limit bytes of , then throws . +internal sealed class LimitedStream(Stream inner, long limit, string message) : Stream { - private readonly Stream _inner; - private readonly long _limit; - private readonly Func _over; - private readonly Action? _onRead; private long _read; - public LimitedStream(Stream inner, long limit, string message) : this(inner, limit, () => new InvalidDataException(message)) - { - } - - /// The exception thrown once more than bytes were read. - /// Called after every read with the number of bytes read so far (for limits shared by several streams). - public LimitedStream(Stream inner, long limit, Func over, Action? onRead = null) - { - _inner = inner; - _limit = limit; - _over = over; - _onRead = onRead; - } - public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; @@ -41,19 +23,17 @@ public override long Position public override int Read(Span buffer) { - var read = _inner.Read(buffer); + var read = inner.Read(buffer); _read += read; - if (_read > _limit) - throw _over(); - if (read > 0) - _onRead?.Invoke(_read); + if (_read > limit) + throw new InvalidDataException(message); return read; } protected override void Dispose(bool disposing) { if (disposing) - _inner.Dispose(); + inner.Dispose(); base.Dispose(disposing); } diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs index 0d8a43885..ea9e9f7a9 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Drawing.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Xml.Linq; -using Filee.Engines.Documents; namespace Filee.Engines.Hwp.Hwpx.Docx; @@ -29,24 +28,8 @@ private IEnumerable Drawing(XElement drawing) var height = HwpxUnits.FromEmu(Long(extent, "cy") ?? 0); var anchor = anchored ? Anchor(container) : null; var data = container.Element(A + "graphic")?.Element(A + "graphicData"); - // Stacking order of floating objects (members of a group share the group's). - var zOrder = anchored && Long(container, "relativeHeight") is { } height32 ? (int?)Math.Min(height32, int.MaxValue) : null; foreach (var inline in GraphicObjects(data?.Elements() ?? [], width, height, anchor, (0, 0, 1, 1))) - { - switch (inline) - { - case HImage image: - image.ZOrder = zOrder; - break; - case HShape shape: - shape.ZOrder = zOrder; - break; - case HTextBox box: - box.ZOrder = zOrder; - break; - } yield return inline; - } } } @@ -59,7 +42,6 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w { foreach (var obj in objects) { - _budget.Object(); if (obj.Name == Pic + "pic") { if (Picture(obj, width, height, anchor) is { } picture) @@ -81,7 +63,6 @@ private IEnumerable GraphicObjects(IEnumerable objects, int w private IEnumerable Group(XElement group, int width, int height, HAnchor? anchor, (double X, double Y, double ScaleX, double ScaleY) parent) { - using var level = _budget.Enter(); var xfrm = group.Element(Wpg + "grpSpPr")?.Element(A + "xfrm") ?? group.Element(A + "grpSpPr")?.Element(A + "xfrm"); var chOff = xfrm?.Element(A + "chOff"); var chExt = xfrm?.Element(A + "chExt"); @@ -130,29 +111,7 @@ private IEnumerable Group(XElement group, int width, int height, HAncho { var blip = pic.Descendants(A + "blip").FirstOrDefault(); var path = ExtractImage(Attr(blip, "embed", R)); - return path is null ? null : new HImage(path) - { - Width = width > 0 ? width : null, - Height = height > 0 ? height : null, - Anchor = anchor, - Crop = Crop(pic.Descendants(A + "srcRect").FirstOrDefault()), - Transform = Transform(pic.Element(Pic + "spPr")?.Element(A + "xfrm")), - }; - } - - /// a:srcRect: the part of the picture cut away at each edge, in 1/1000 percent. - private static HCrop? Crop(XElement? sourceRect) - { - double Edge(string name) => Math.Clamp((Long(sourceRect, name) ?? 0) / 100000.0, 0, 1); - var crop = new HCrop(Edge("l"), Edge("t"), Edge("r"), Edge("b")); - return sourceRect is null || crop.IsEmpty ? null : crop; - } - - /// Rotation (a:xfrm rot, 1/60000 degree, clockwise) and mirroring; null when there is neither. - private static HTransform? Transform(XElement? xfrm) - { - var transform = new HTransform((Long(xfrm, "rot") ?? 0) / 60000.0, Attr(xfrm, "flipH") is "1" or "true", Attr(xfrm, "flipV") is "1" or "true"); - return transform.IsIdentity ? null : transform; + return path is null ? null : new HImage(path) { Width = width > 0 ? width : null, Height = height > 0 ? height : null, Anchor = anchor }; } /// A text box, or a rectangle / ellipse / line without text. Other geometries are skipped. @@ -203,7 +162,6 @@ private IEnumerable Group(XElement group, int width, int height, HAncho CornerRatio = geometry is "roundRect" or "round1Rect" or "round2SameRect" or "flowChartAlternateProcess" ? 20 : 0, FlipHorizontal = Attr(xfrm, "flipH") is "1" or "true", FlipVertical = Attr(xfrm, "flipV") is "1" or "true", - Transform = Transform(xfrm), }; } @@ -225,7 +183,6 @@ private HTextBox TextBox(XElement wsp, XElement content, int width, int height, _ => HVerticalAlign.Top, }, }.Build(); - box.Transform = Transform(spPr?.Element(A + "xfrm")); ReadTextBoxContent(content, box.Blocks); return box; } @@ -233,7 +190,6 @@ private HTextBox TextBox(XElement wsp, XElement content, int width, int height, /// Text box paragraphs; Word's document grid does not apply inside text boxes. private void ReadTextBoxContent(XElement content, List output) { - using var level = _budget.Enter(); _textBoxDepth++; try { @@ -404,12 +360,13 @@ private static int OffsetOf(XElement? position) => return null; if (_media.TryGetValue(rel.Target, out var existing)) return existing; - if (_package.GetEntry(rel.Target) is not { } entry) + if (_zip.GetEntry(rel.Target) is not { } entry) return null; Directory.CreateDirectory(_mediaFolder); - string? path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{DocumentPackage.SafeExtension(rel.Target)}"); - if (!_package.ExtractPicture(entry, path)) - path = null; // larger than any picture should be: left out + var path = Path.Combine(_mediaFolder, $"docx-media-{_media.Count + 1}{Path.GetExtension(rel.Target)}"); + using (var source = entry.Open()) + using (var target = File.Create(path)) + source.CopyTo(target); _media[rel.Target] = path; return path; } @@ -425,12 +382,10 @@ private IEnumerable Vml(XElement container, HCharFormat format) continue; if (shape.Name.LocalName == "group") { - using var level = _budget.Enter(); foreach (var inline in Vml(shape, format)) yield return inline; continue; } - _budget.Object(); if (shape.Name.LocalName is "shapetype") continue; @@ -438,12 +393,10 @@ private IEnumerable Vml(XElement container, HCharFormat format) var width = HwpxUnits.ParseLength(style.GetValueOrDefault("width")) ?? 0; var height = HwpxUnits.ParseLength(style.GetValueOrDefault("height")) ?? 0; var anchor = style.GetValueOrDefault("position") == "absolute" ? VmlAnchor(shape, style) : null; - var transform = VmlTransform(style); - int? zOrder = anchor is not null && int.TryParse(style.GetValueOrDefault("z-index"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var z) ? z : null; if (shape.Element(V + "imagedata") is { } imageData && ExtractImage(Attr(imageData, "id", R)) is { } path) { - yield return new HImage(path) { Width = width > 0 ? width : null, Height = height > 0 ? height : null, Anchor = anchor, Transform = transform, ZOrder = zOrder }; + yield return new HImage(path) { Width = width > 0 ? width : null, Height = height > 0 ? height : null, Anchor = anchor }; } else if (shape.Element(V + "textbox")?.Element(W + "txbxContent") is { } content) { @@ -452,7 +405,6 @@ private IEnumerable Vml(XElement container, HCharFormat format) Fill = VmlFill(shape), Line = VmlLine(shape), }.Build(); - (box.Transform, box.ZOrder) = (transform, zOrder); ReadTextBoxContent(content, box.Blocks); yield return box; } @@ -501,8 +453,6 @@ private IEnumerable Vml(XElement container, HCharFormat format) Fill = kind == HShapeKind.Line ? null : VmlFill(shape), Line = VmlLine(shape), CornerRatio = shape.Name.LocalName == "roundrect" ? 20 : 0, - Transform = transform, - ZOrder = zOrder, }; } } @@ -544,23 +494,9 @@ private IEnumerable Vml(XElement container, HCharFormat format) Bold = font.GetValueOrDefault("font-weight") is { } weight ? weight == "bold" || int.TryParse(weight, out var w) && w >= 600 : format.Bold, Italic = font.GetValueOrDefault("font-style") is { } italic ? italic == "italic" : format.Italic, Color = color ?? format.Color, - Layout = family is null || format.Layout is null ? format.Layout : format.Layout with { WordFonts = new HWordFonts(family, family, family, family) }, }); } - /// VML rotation (style "rotation", degrees clockwise) and mirroring ("flip: x y"); null when there is neither. - private static HTransform? VmlTransform(Dictionary style) - { - var rotation = style.GetValueOrDefault("rotation")?.TrimEnd('f', 'd') is { } value && - double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var degrees) ? degrees : 0; - // "fd" values are in 1/65536 degree. - if (style.GetValueOrDefault("rotation")?.EndsWith("fd", StringComparison.Ordinal) == true) - rotation /= 65536; - var flip = style.GetValueOrDefault("flip")?.Split(' ', StringSplitOptions.RemoveEmptyEntries) ?? []; - var transform = new HTransform(rotation, flip.Contains("x"), flip.Contains("y")); - return transform.IsIdentity ? null : transform; - } - private static string? VmlFill(XElement shape) => (string?)shape.Attribute("filled") is "f" or "false" ? null : VmlColor((string?)shape.Attribute("fillcolor")) ?? "#FFFFFF"; diff --git a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs index 537fcc603..d944fa695 100644 --- a/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs +++ b/src/Filee.Engines/Hwp/Hwpx/Docx/DocxReader.Styles.cs @@ -35,20 +35,10 @@ private sealed class ParaProps public int? Ilvl; public List<(int Position, string Kind, string Leader)> Tabs = []; - /// Paragraph border edges (w:pBdr: top, left, bottom, right, between) with their distance to the text in points. - public Dictionary Borders = []; - - /// Paragraph background (w:shd); null = none. - public string? Shading; - - /// East Asian typography switches (w:kinsoku, w:wordWrap, w:overflowPunct, w:topLinePunct, w:autoSpaceDE / DN). - public bool? Kinsoku, WordWrap, OverflowPunct, TopLinePunct, AutoSpaceDE, AutoSpaceDN; - public ParaProps Clone() { var copy = (ParaProps)MemberwiseClone(); copy.Tabs = [.. Tabs]; - copy.Borders = new Dictionary(Borders); return copy; } @@ -96,24 +86,6 @@ public void Apply(XElement? pPr) PageBreakBefore = Toggle(pPr, "pageBreakBefore") ?? PageBreakBefore; WidowControl = Toggle(pPr, "widowControl") ?? WidowControl; SnapToGrid = Toggle(pPr, "snapToGrid") ?? SnapToGrid; - Kinsoku = Toggle(pPr, "kinsoku") ?? Kinsoku; - WordWrap = Toggle(pPr, "wordWrap") ?? WordWrap; - OverflowPunct = Toggle(pPr, "overflowPunct") ?? OverflowPunct; - TopLinePunct = Toggle(pPr, "topLinePunct") ?? TopLinePunct; - AutoSpaceDE = Toggle(pPr, "autoSpaceDE") ?? AutoSpaceDE; - AutoSpaceDN = Toggle(pPr, "autoSpaceDN") ?? AutoSpaceDN; - foreach (var edge in pPr.Element(W + "pBdr")?.Elements() ?? []) - { - var side = edge.Name.LocalName switch - { - "start" => "left", - "end" => "right", - var other => other, - }; - Borders[side] = (Border(edge), Twips(edge, "space") ?? 0); - } - if (pPr.Element(W + "shd") is { } shd) - Shading = ShadingColor(shd); if (pPr.Element(W + "outlineLvl") is { } outline && int.TryParse(Val(outline), out var level)) OutlineLevel = level; if (pPr.Element(W + "numPr") is { } numPr) @@ -144,12 +116,6 @@ private sealed class RunProps public string? VertAlign, Color, Highlight, Shade, Ascii, EastAsia, AsciiTheme, EastAsiaTheme; public int? Size, Spacing; - /// The four Word fonts one by one (Ascii above falls back to hAnsi for the writers) and the hint. - public string? AsciiOnly, AsciiOnlyTheme, HighAnsi, HighAnsiTheme, ComplexScript, ComplexScriptTheme, Hint; - - /// w:w (percent) and w:position (half points). - public int? Scale, Position; - public RunProps Clone() => (RunProps)MemberwiseClone(); public void Apply(XElement? rPr) @@ -174,26 +140,8 @@ public void Apply(XElement? rPr) Size = halfPoints; if (rPr.Element(W + "spacing") is { } spacing && int.TryParse(Val(spacing), out var twips)) Spacing = twips; - if (rPr.Element(W + "w") is { } scale && int.TryParse(Val(scale), out var percent)) - Scale = percent; - if (rPr.Element(W + "position") is { } position && int.TryParse(Val(position), out var shift)) - Position = shift; if (rPr.Element(W + "rFonts") is { } fonts) { - if (Attr(fonts, "ascii") is { } asciiOnly) - (AsciiOnly, AsciiOnlyTheme) = (asciiOnly, null); - if (Attr(fonts, "asciiTheme") is { } asciiOnlyTheme) - AsciiOnlyTheme = asciiOnlyTheme; - if (Attr(fonts, "hAnsi") is { } highAnsi) - (HighAnsi, HighAnsiTheme) = (highAnsi, null); - if (Attr(fonts, "hAnsiTheme") is { } highAnsiTheme) - HighAnsiTheme = highAnsiTheme; - if (Attr(fonts, "cs") is { } complex) - (ComplexScript, ComplexScriptTheme) = (complex, null); - if (Attr(fonts, "cstheme") is { } complexTheme) - ComplexScriptTheme = complexTheme; - Hint = Attr(fonts, "hint") ?? Hint; - // A theme reference wins over an explicit name in Word, so a later explicit name clears it. if ((Attr(fonts, "ascii") ?? Attr(fonts, "hAnsi")) is { } ascii) (Ascii, AsciiTheme) = (ascii, null); @@ -235,7 +183,6 @@ private sealed class NumberingLevel private string? _defaultParagraphStyle; private string? _defaultTableStyle; private (string? Latin, string? EastAsian) _minorFont, _majorFont; - private string? _minorComplexFont, _majorComplexFont; private void LoadStyles(Part? part) { @@ -285,8 +232,6 @@ private void LoadTheme(Part? part) } _minorFont = Read("minorFont"); _majorFont = Read("majorFont"); - _minorComplexFont = NullIfEmpty(Attr(fonts?.Element(A + "minorFont")?.Element(A + "cs"), "typeface")); - _majorComplexFont = NullIfEmpty(Attr(fonts?.Element(A + "majorFont")?.Element(A + "cs"), "typeface")); } private void LoadNumbering(Part? part) @@ -294,10 +239,8 @@ private void LoadNumbering(Part? part) var root = part?.Xml.Root; if (root is null) return; - // Of two definitions with one id (a damaged file) the first counts. - var abstracts = new Dictionary(); - foreach (var abstractNum in root.Elements(W + "abstractNum")) - abstracts.TryAdd(Attr(abstractNum, "abstractNumId") ?? "", abstractNum); + var abstracts = root.Elements(W + "abstractNum") + .ToDictionary(a => Attr(a, "abstractNumId") ?? "", a => a); foreach (var num in root.Elements(W + "num")) { var numId = Attr(num, "numId") ?? ""; @@ -337,11 +280,7 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // ───────────────────────── Resolution ───────────────────────── /// Merged properties of a paragraph or character style including everything it is based on. - private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) => ResolveStyle(styleId, 0); - - /// Styles below this one in the chain: a chain longer than (a - /// damaged or hostile file; real chains are a few long) starts from the defaults instead of overflowing the stack. - private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId, int depth) + private (ParaProps Para, RunProps Run) ResolveStyle(string? styleId) { if (styleId is null || !_styles.TryGetValue(styleId, out var style)) return (_defaultPara.Clone(), _defaultRun.Clone()); @@ -350,9 +289,7 @@ static void ApplyLevel(NumberingLevel[] levels, XElement lvl) // Guard against basedOn cycles in damaged files. _resolvedStyles[styleId] = (_defaultPara.Clone(), _defaultRun.Clone()); - var (para, run) = style.BasedOn is { } parent && parent != styleId && depth < MaxStyleChain - ? ResolveStyle(parent, depth + 1) - : (_defaultPara.Clone(), _defaultRun.Clone()); + var (para, run) = style.BasedOn is { } parent && parent != styleId ? ResolveStyle(parent) : (_defaultPara.Clone(), _defaultRun.Clone()); para.Apply(style.PPr); run.Apply(style.RPr); _resolvedStyles[styleId] = (para.Clone(), run.Clone()); @@ -395,7 +332,7 @@ private RunProps RunProperties(XElement? rPr, RunProps paragraphRun) { // Character styles only carry run properties; apply their chain on top of the paragraph's. var chain = new List") }); - var error = Assert.IsType(OnSmallStack(() => DocxReader.Read(path, Media()))); - Assert.Contains(part, error.Message); - } - } - - [Theory] - [InlineData("colCnt=\"2147483647\"", "colAddr=\"0\"", "colSpan=\"1\"")] // fuzzer: OutOfMemoryException, array dimensions exceeded - [InlineData("colCnt=\"2\"", "colAddr=\"2147483647\"", "colSpan=\"2147483647\"")] // fuzzer: ArgumentOutOfRangeException in ColumnWidths - [InlineData("colCnt=\"3\"", "colAddr=\"0\"", "colSpan=\"3\"")] // found with them: the widths of the cells below overflowed their sum - public void Fuzz_finding_hwpx_tables_with_column_numbers_at_the_end_of_the_range_read(string count, string address, string span) - { - static string Cell(string text, int column, int row, string width) => - $"{text}" + - $""; - var cell = $"칸" + - $""; - var below = Cell("둘", 0, 1, "2147483647") + Cell("셋", 1, 1, "2147483647"); - var hwpx = Hwpx(Hp($"{cell}{below}")); - HDocument? read = null; - Reads(() => read = HwpxReader.Read(hwpx, Media())); - var table = read!.Sections[0].Blocks.OfType().Single(); - Assert.InRange(table.ColumnCount, 1, DocumentLimits.Default.MaxTableColumns); - Assert.Equal(2, table.Rows[0].Cells[0].RowSpan); // no further than the last row - } - - // ───────────────────────── Layout guards ───────────────────────── - - [Fact] - public void The_layout_stops_at_the_page_limit_and_after_too_many_empty_pages() - { - var limits = DocumentLimits.Default with { MaxPages = 5, MaxConsecutiveEmptyPages = 2 }; - var guard = new LayoutGuard(limits, CancellationToken.None); - for (var i = 0; i < 5; i++) - guard.PageFinished(empty: false); - Assert.Contains("more than 5 pages", Assert.Throws(() => guard.PageFinished(empty: false)).Detail); - - var stuck = new LayoutGuard(limits with { MaxPages = 100 }, CancellationToken.None); - stuck.PageFinished(empty: true); - stuck.PageFinished(empty: true); - stuck.PageFinished(empty: false); // something placed: the count starts again - stuck.PageFinished(empty: true); - stuck.PageFinished(empty: true); - Assert.Contains("more than 2 empty pages in a row", Assert.Throws(() => stuck.PageFinished(empty: true)).Detail); - } - - [Fact] - public void Every_paragraph_checks_cancellation_and_the_time_budget() - { - var elapsed = TimeSpan.Zero; - var guard = new LayoutGuard(DocumentLimits.Default with { LayoutTimeBudget = TimeSpan.FromMinutes(1) }, CancellationToken.None, () => elapsed); - guard.Paragraph(); - elapsed = TimeSpan.FromSeconds(61); - var refusal = Assert.Throws(guard.Paragraph); - Assert.Equal(DocumentLimits.ErrorKey, refusal.ErrorKey); - Assert.Contains("longer than 1 minute.", refusal.Detail); - - using var cancelled = new CancellationTokenSource(); - var cancellable = new LayoutGuard(DocumentLimits.Default, cancelled.Token); - cancellable.Paragraph(); - cancelled.Cancel(); - Assert.Throws(cancellable.Paragraph); - } - - [Theory] - [InlineData(100, 50, 200, 300, 100, 50, false)] // fits - [InlineData(500, 50, 200, 300, 200, 50, true)] // too wide: clipped to the column - [InlineData(100, 9e9, 200, 300, 100, 300, true)] // taller than any page: clipped and placed, not moved on - [InlineData(double.NaN, double.PositiveInfinity, 200, 300, 0, 0, false)] // sizes that are no numbers - [InlineData(-5, 10, 200, 300, 0, 10, false)] - public void An_object_that_fits_no_page_is_clipped_and_placed(double width, double height, double areaWidth, double areaHeight, - double placedWidth, double placedHeight, bool clipped) - { - Assert.Equal((placedWidth, placedHeight, clipped), LayoutGuard.Fit(width, height, areaWidth, areaHeight)); - } -} diff --git a/tests/Filee.Engines.Tests/DocumentModelTests.cs b/tests/Filee.Engines.Tests/DocumentModelTests.cs deleted file mode 100644 index e7fd13beb..000000000 --- a/tests/Filee.Engines.Tests/DocumentModelTests.cs +++ /dev/null @@ -1,605 +0,0 @@ -// The rendering detail of the document model (HwpxModel.Layout.cs): what HwpxReader and DocxReader fill in, that it -// leaves written files alone, and StyleResolver. Every input is generated here. - -using System.IO.Compression; -using System.Text; -using Filee.Engines.Hwp.Hwpx; -using Filee.Engines.Hwp.Hwpx.Docx; -using ImageMagick; -using static Filee.Engines.Tests.DocxBuilder; - -namespace Filee.Engines.Tests; - -public class DocumentModelTests(EngineFixture fx) : IClassFixture -{ - // ───────────────────────── HWPX packages ───────────────────────── - - private const string Namespaces = - "xmlns:hp=\"http://www.hancom.co.kr/hwpml/2011/paragraph\" xmlns:hs=\"http://www.hancom.co.kr/hwpml/2011/section\" " + - "xmlns:hc=\"http://www.hancom.co.kr/hwpml/2011/core\" xmlns:hh=\"http://www.hancom.co.kr/hwpml/2011/head\" " + - "xmlns:hm=\"http://www.hancom.co.kr/hwpml/2011/master-page\""; - - private const string Case = ""; - - private static string Margins(int intent = 0, int left = 0) => - $""; - - private static string LineSpacing(string type, int value) => - $"{Case}{Margins()}" + - $"{Margins()}"; - - private static string CharPr(string id, string fontRef = "0", string ratio = "100", string spacing = "0", string relSz = "100", string offset = "0") - { - static string Slots(string name, string values) - { - var parts = values.Split(','); - string Value(int i) => parts.Length == 1 ? parts[0] : parts[i]; - return $""; - } - return $"" + - Slots("fontRef", fontRef) + Slots("ratio", ratio) + Slots("spacing", spacing) + Slots("relSz", relSz) + Slots("offset", offset) + - ""; - } - - private static string ParaPr(string id, string spacing, string attributes = "condense=\"0\" fontLineHeight=\"0\" snapToGrid=\"1\"", - string breaks = "breakLatinWord=\"KEEP_WORD\" breakNonLatinWord=\"KEEP_WORD\" lineWrap=\"BREAK\"", string autoSpacing = "eAsianEng=\"0\" eAsianNum=\"0\"", - string border = "borderFillIDRef=\"1\" offsetLeft=\"0\" offsetRight=\"0\" offsetTop=\"0\" offsetBottom=\"0\" connect=\"0\" ignoreMargin=\"0\"") => - $"" + - "" + - $"" + - $"{spacing}"; - - private static string BorderFill(string id, string edge, string fill = "") => - $"" + - "" + - $"" + - $"{fill}"; - - private static string Hp(string runs, string paraPr = "0", string lines = "") => - $"{runs}{lines}"; - - private static string Hr(string content, string charPr = "0") => $"{content}"; - - private static string Ht(string text) => $"{text}"; - - private static string SubList(string paragraphs) => - $"{paragraphs}"; - - private const string FloatingPos = - ""; - - private const string Outline = - "" + - ""; - - /// A drawing object (rect, polygon, curve, arc ...) floating at the paragraph, with its own children. - private static string Drawing(string name, string attributes, string children, string transform = "", int zOrder = 0) => - $"" + - $"{transform}{Outline}{children}" + - $"{FloatingPos}"; - - /// An HWPX package: header.xml from , one section, extra parts and manifest items. - private static string Package(string path, string refList, string section, string secPrChildren = "", Dictionary? parts = null, - bool estimatedLines = false, byte[]? picture = null) - { - var header = - $"" + - "" + string.Concat(new[] { "HANGUL", "LATIN", "HANJA", "JAPANESE", "OTHER", "SYMBOL", "USER" }.Select(lang => - $"")) + - "" + refList + - "" + - ""; - var secPr = - "" + - "" + - secPrChildren + ""; - var body = $"{Hp(Hr(secPr))}{section}"; - - var items = new StringBuilder(""); - foreach (var name in parts?.Keys.ToList() ?? []) - items.Append($""); - if (picture is not null) - items.Append(""); - var meta = estimatedLines ? "" : ""; - var hpf = "t" + meta + "" + - $"{items}"; - - using var zip = ZipFile.Open(path, ZipArchiveMode.Create); - void Write(string name, string text) - { - using var writer = new StreamWriter(zip.CreateEntry(name).Open(), new UTF8Encoding(false)); - writer.Write(text); - } - Write("mimetype", "application/hwp+zip"); - Write("Contents/content.hpf", hpf); - Write("Contents/header.xml", "" + header); - Write("Contents/section0.xml", body); - foreach (var (name, xml) in parts ?? []) - Write(name, xml); - if (picture is not null) - { - using var stream = zip.CreateEntry("BinData/image1.png").Open(); - stream.Write(picture); - } - return path; - } - - /// borderFill 1 (no lines), charPr 0 and paraPr 0 (160%), plus the given items. - private static string RefList(string borderFills = "", string charPrs = "", string paraPrs = "") => - $"{BorderFill("1", "type=\"NONE\" width=\"0.1 mm\" color=\"#000000\"")}{borderFills}" + - $"{CharPr("0")}{charPrs}" + - "" + - $"{ParaPr("0", LineSpacing("PERCENT", 160))}{paraPrs}"; - - private HDocument ReadHwpx(string path, bool forLayout = false, Dictionary? skipped = null) - { - var media = Path.Combine(Path.GetDirectoryName(path)!, "media"); - return forLayout ? HwpxReader.ReadForLayout(path, media, skipped) : HwpxReader.Read(path, media, skipped); - } - - private static List Paragraphs(HDocument document) => document.Sections.SelectMany(s => s.Blocks).OfType().ToList(); - - // ───────────────────────── HWPX: characters ───────────────────────── - - [Fact] - public void Hwpx_character_shapes_keep_all_seven_language_slots() - { - var path = Package(Path.Combine(fx.NewFolder(), "slots.hwpx"), - RefList(charPrs: CharPr("1", fontRef: "1,0,1,0,1,0,1", ratio: "90,80,100,100,100,100,100", spacing: "-5,10,0,0,0,0,0", relSz: "100,95,100,100,100,100,100", offset: "0,-10,0,0,0,0,0")), - Hp(Hr(Ht("한글 Latin"), "1"))); - - var text = Assert.IsType(Paragraphs(ReadHwpx(path))[1].Inlines.Single()); - - // What the writers use stays as before: the Latin and Hangul faces, the Hangul spacing. - Assert.Equal(("LATIN-0", "HANGUL-1", -5), (text.Format.Font, text.Format.EastAsianFont, text.Format.Spacing)); - var slots = text.Format.Layout!.Slots!; - Assert.Equal(new HSlotFormat("HANGUL-1", 90, -5, 100, 0), slots.Hangul); - Assert.Equal(new HSlotFormat("LATIN-0", 80, 10, 95, -10), slots.Latin); - Assert.Equal(["HANGUL-1", "LATIN-0", "HANJA-1", "JAPANESE-0", "OTHER-1", "SYMBOL-0", "USER-1"], HLanguageSlots.Languages.Select(l => slots[l].Font)); - } - - [Fact] - public void Runs_that_differ_only_in_layout_detail_stay_apart_when_read_for_layout() - { - var path = Package(Path.Combine(fx.NewFolder(), "runs.hwpx"), - RefList(charPrs: CharPr("1", ratio: "80")), - Hp(Hr(Ht("보통")) + Hr(Ht("좁게"), "1"))); - - // For the writers the runs look alike and are one text, as before; the layout engine sees both. - var merged = Paragraphs(ReadHwpx(path))[1].Inlines; - Assert.Equal("보통좁게", Assert.IsType(Assert.Single(merged)).Text); - var apart = Paragraphs(ReadHwpx(path, forLayout: true))[1].Inlines.Cast().ToList(); - Assert.Equal([("보통", 100), ("좁게", 80)], apart.Select(t => (t.Text, t.Format.Layout!.Slots!.Hangul.Ratio!.Value))); - Assert.Equal(apart[0].Format, apart[1].Format); - Assert.False(apart[0].Format.SameLayout(apart[1].Format)); - } - - // ───────────────────────── HWPX: paragraphs ───────────────────────── - - [Fact] - public void Hwpx_paragraph_shapes_keep_line_rules_borders_and_line_breaking() - { - var path = Package(Path.Combine(fx.NewFolder(), "paragraphs.hwpx"), - RefList( - borderFills: BorderFill("2", "type=\"SOLID\" width=\"0.4 mm\" color=\"#0000FF\"", ""), - paraPrs: - ParaPr("1", LineSpacing("BETWEEN_LINES", 500), "condense=\"25\" fontLineHeight=\"1\" snapToGrid=\"0\"", - "breakLatinWord=\"HYPHENATION\" breakNonLatinWord=\"BREAK_WORD\" lineWrap=\"SQUEEZE\"", "eAsianEng=\"1\" eAsianNum=\"0\"", - "borderFillIDRef=\"2\" offsetLeft=\"100\" offsetRight=\"200\" offsetTop=\"300\" offsetBottom=\"400\" connect=\"1\" ignoreMargin=\"1\"") + - ParaPr("2", LineSpacing("FIXED", 1800))), - Hp(Hr(Ht("상자")), "1") + Hp(Hr(Ht("고정")), "2")); - - var paragraphs = Paragraphs(ReadHwpx(path)); - var boxed = paragraphs[1].Format; - // The writers' line spacing is unchanged; the rule keeps 한글's meaning ("space between lines only"). - Assert.Equal(new HLineSpacing(HLineSpacingKind.AtLeast, 1500), boxed.LineSpacing); - var layout = boxed.Layout!; - Assert.Equal(new HLineHeight(HLineHeightRule.BetweenLines, 500), layout.LineHeight); - Assert.Equal((false, true), (layout.SnapToGrid, layout.FontLineHeight)); - Assert.Equal(new HLineBreaking(HLatinBreak.Hyphenation, HNonLatinBreak.BreakWord, HLineWrap.Squeeze, AutoSpaceLatin: true, AutoSpaceDigits: false, Condense: 25), layout.Breaking); - var border = layout.Border!; - Assert.Equal(new HBorder(HBorderStyle.Solid, 0.4, "#0000FF"), border.Borders.Top); - Assert.Equal(("#FFF2CC", new HInsets(100, 200, 300, 400), true, true), (border.Fill, border.Offsets, border.Connect, border.IgnoreMargin)); - Assert.True(border.IsVisible); - - Assert.Equal(new HLineHeight(HLineHeightRule.Exact, 1800), paragraphs[2].Format.Layout!.LineHeight); - Assert.False(paragraphs[2].Format.Layout!.Border!.IsVisible); - Assert.Equal(new HLineHeight(HLineHeightRule.Percent, 160), paragraphs[0].Format.Layout!.LineHeight); - } - - [Fact] - public void Hwpx_stored_line_layout_is_kept_and_marked_when_Filee_estimated_it() - { - const string Lines = - "" + - ""; - var dir = fx.NewFolder(); - var section = Hp(Hr(Ht("두 줄로 나뉜 문단입니다")), lines: Lines) + Hp(Hr(Ht("저장된 줄 없음"))); - - var stored = Paragraphs(ReadHwpx(Package(Path.Combine(dir, "stored.hwpx"), RefList(), section))); - var lines = stored[1].Lines!; - Assert.False(lines.Estimated); - Assert.Equal([new HLineSegment(0, 0, 1000, 1000, 850, 600, 0, 42520, 393216), new HLineSegment(12, 1600, 1000, 1000, 850, 600, 100, 42420, 0)], lines.Segments); - Assert.Null(stored[2].Lines); - - var estimated = Paragraphs(ReadHwpx(Package(Path.Combine(dir, "estimated.hwpx"), RefList(), section, estimatedLines: true))); - Assert.True(estimated[1].Lines!.Estimated); - } - - [Fact] - public void Hwpx_sections_keep_their_grid_and_tables_their_page_break_mode() - { - var table = - "" + - "" + FloatingPos + "" + - "" + SubList(Hp(Hr(Ht("칸")))) + - ""; - var path = Package(Path.Combine(fx.NewFolder(), "grid.hwpx"), RefList(), Hp(Hr(table)), ""); - - var document = ReadHwpx(path); - - Assert.Equal(new HDocumentGrid(HGridKind.Lines, 1600), document.Sections[0].Grid); - Assert.Equal(HTablePageBreak.None, document.Sections[0].Blocks.OfType().Single().PageBreak); - } - - [Fact] - public void Hwpx_master_pages_are_read_for_layout() - { - var master = - $"" + - SubList(Hp(Hr(Ht("바탕쪽 글")))) + ""; - var path = Package(Path.Combine(fx.NewFolder(), "master.hwpx"), RefList(), Hp(Hr(Ht("본문"))), - "", new() { ["Contents/masterpage0.xml"] = master }); - - Assert.Empty(ReadHwpx(path).Sections[0].MasterPages); - var page = Assert.Single(ReadHwpx(path, forLayout: true).Sections[0].MasterPages); - Assert.Equal((HMasterPageKind.Even, true, false), (page.Kind, page.Duplicate, page.Front)); - Assert.Equal("바탕쪽 글", Assert.IsType(Assert.IsType(Assert.Single(page.Blocks)).Inlines.Single()).Text); - } - - // ───────────────────────── HWPX: objects ───────────────────────── - - [Fact] - public void Hwpx_pictures_keep_crop_rotation_mirroring_and_z_order() - { - using var image = new MagickImage(MagickColors.Teal, 40, 20); - var picture = - "" + - "" + - "" + - "" + - "" + - "" + - $"{FloatingPos}"; - var path = Package(Path.Combine(fx.NewFolder(), "picture.hwpx"), RefList(), Hp(Hr(picture)), picture: image.ToByteArray(MagickFormat.Png)); - - var read = Assert.IsType(Paragraphs(ReadHwpx(path))[1].Inlines.Single()); - - Assert.Equal(new HCrop(0.1, 0, 0.1, 0.2), read.Crop); - Assert.Equal(new HTransform(30, FlipHorizontal: true), read.Transform); - Assert.Equal(7, read.ZOrder); - } - - [Fact] - public void Hwpx_polygons_curves_and_arcs_are_shapes_when_read_for_layout() - { - var polygon = Drawing("polygon", "", "", zOrder: 3); - var curve = Drawing("curve", "", "", - ""); - var arc = Drawing("arc", "type=\"PIE\"", ""); - var labelled = Drawing("polygon", "", "" + SubList(Hp(Hr(Ht("다각형 글")))) + - ""); - var path = Package(Path.Combine(fx.NewFolder(), "shapes.hwpx"), RefList(), Hp(Hr(polygon + curve + arc + labelled))); - - // For the writers nothing changes: shapes they cannot draw are skipped, text keeps a box without outline. - var skipped = new Dictionary(); - var plain = Paragraphs(ReadHwpx(path, skipped: skipped))[1].Inlines; - Assert.Equal((1, 1, 1), (skipped["polygon"], skipped["curve"], skipped["arc"])); - Assert.Equal(HBorderStyle.None, Assert.IsType(Assert.Single(plain)).Line.Style); - - var inlines = Paragraphs(ReadHwpx(path, forLayout: true))[1].Inlines; - var shape = Assert.IsType(inlines[0]); - Assert.Equal((HShapeKind.Polygon, 8000, 4000, 3, "#00FF00", "#FF0000"), (shape.Kind, shape.Width, shape.Height, shape.ZOrder, shape.Fill, shape.Line.Color)); - Assert.Equal((4000, 2000), (shape.Geometry!.Width, shape.Geometry.Height)); - Assert.Equal([new HPoint(0, 0), new HPoint(4000, 0), new HPoint(2000, 2000), new HPoint(0, 0)], shape.Geometry.Points!); - - var curved = Assert.IsType(inlines[1]); - Assert.Equal(HShapeKind.Curve, curved.Kind); - Assert.Equal([new HCurveSegment(true, new HPoint(0, 2000), new HPoint(1000, 0)), new HCurveSegment(false, new HPoint(1000, 0), new HPoint(4000, 2000))], curved.Geometry!.Segments!); - Assert.Equal(new HTransform(90, FlipVertical: true), curved.Transform); - - var pie = Assert.IsType(inlines[2]); - Assert.Equal(new HArc(HArcKind.Pie, new HPoint(2000, 1000), new HPoint(4000, 1000), new HPoint(2000, 0)), pie.Geometry!.Arc); - - var box = Assert.IsType(inlines[3]); - Assert.Equal((HShapeKind.Polygon, HBorderStyle.Solid, "#00FF00", 3), (box.Shape, box.Line.Style, box.Fill, box.Geometry!.Points!.Count)); - } - - [Fact] - public void Hwpx_rectangles_keep_rotation_and_z_order() - { - var rect = Drawing("rect", "ratio=\"0\"", "", - "", zOrder: 9); - var path = Package(Path.Combine(fx.NewFolder(), "rect.hwpx"), RefList(), Hp(Hr(rect))); - - var shape = Assert.IsType(Paragraphs(ReadHwpx(path))[1].Inlines.Single()); - - Assert.Equal((new HTransform(45, FlipHorizontal: true), 9), (shape.Transform, shape.ZOrder)); - // The mirroring the writers use is only for lines, whose direction it is. - Assert.False(shape.FlipHorizontal); - } - - // ───────────────────────── DOCX ───────────────────────── - - private HDocument ReadDocx(DocxBuilder docx) - { - var dir = fx.NewFolder(); - return DocxReader.Read(docx.Save(Path.Combine(dir, "test.docx")), Path.Combine(dir, "media")); - } - - [Fact] - public void Docx_line_rules_keep_their_meaning_and_the_document_grid() - { - var docx = new DocxBuilder(); - docx.Paragraph(P(R("배수"), "")) - .Paragraph(P(R("고정"), "")) - .Paragraph(P(R("최소"), "")) - .Paragraph(P(R("격자"), "")) - .Paragraph(P(R("격자 무시"), "")); - docx.SectionProperties = DefaultPage + ""; - - var document = ReadDocx(docx); - var paragraphs = Paragraphs(document); - - // 1.15 lines on an 18 pt grid: the writers still get the snapped "at least" height, the rule stays Word's multiple with its grid. - Assert.Equal(new HDocumentGrid(HGridKind.Lines, 1800, CharSpace: -1024), document.Sections[0].Grid); - Assert.Equal(new HLineSpacing(HLineSpacingKind.AtLeast, 1800 * 276 / 240), paragraphs[0].Format.LineSpacing); - Assert.Equal(new HLineHeight(HLineHeightRule.Multiple, 276, GridPitch: 1800), paragraphs[0].Format.Layout!.LineHeight); - Assert.Equal(new HLineHeight(HLineHeightRule.Exact, 1800), paragraphs[1].Format.Layout!.LineHeight); - Assert.Equal(new HLineHeight(HLineHeightRule.AtLeast, 1500), paragraphs[2].Format.Layout!.LineHeight); - Assert.Equal(new HLineHeight(HLineHeightRule.Multiple, 240, GridPitch: 1800), paragraphs[3].Format.Layout!.LineHeight); - Assert.Equal((new HLineHeight(HLineHeightRule.Multiple, 240), false), (paragraphs[4].Format.Layout!.LineHeight, paragraphs[4].Format.Layout!.SnapToGrid)); - Assert.Equal(new HLineSpacing(HLineSpacingKind.Percent, 130), paragraphs[4].Format.LineSpacing); - } - - [Fact] - public void Docx_runs_keep_their_four_fonts_scale_spacing_and_position() - { - var docx = new DocxBuilder(); - docx.Paragraph(P(R("글자", ""))) - .Paragraph(P(R("테마", ""))); - - var paragraphs = Paragraphs(ReadDocx(docx)); - - var format = Assert.IsType(paragraphs[0].Inlines.Single()).Format; - Assert.Equal(("Arial", "바탕"), (format.Font, format.EastAsianFont)); // what the writers use - Assert.Equal(new HCharLayout(WordFonts: new HWordFonts("Arial", "Calibri", "바탕", "Arial Unicode MS", "eastAsia"), Scale: 80, LetterSpacing: 200, Position: 300), format.Layout); - // Without a theme part the complex script theme font is unknown; the other fonts come from the defaults. - Assert.Equal(new HWordFonts("맑은 고딕", "맑은 고딕", "맑은 고딕", null), Assert.IsType(paragraphs[1].Inlines.Single()).Format.Layout!.WordFonts); - } - - [Fact] - public void Docx_paragraphs_keep_borders_shading_and_line_breaking() - { - var docx = new DocxBuilder(); - docx.Paragraph(P(R("테두리"), - "" + - "" + - "")) - .Paragraph(P(R("기본"))); - - var paragraphs = Paragraphs(ReadDocx(docx)); - - var border = paragraphs[0].Format.Layout!.Border!; - Assert.Equal(new HBorder(HBorderStyle.Solid, 8 / 8.0 * 25.4 / 72, "#FF0000"), border.Borders.Top); - Assert.Equal(HBorderStyle.None, border.Borders.Bottom.Style); - Assert.Equal((HBorderStyle.Dash, "#DDEEFF", 400, true), (border.Between!.Value.Style, border.Fill, border.Offsets.Top, border.Connect)); - Assert.Equal(new HLineBreaking(HLatinBreak.BreakWord, Kinsoku: false, OverflowPunctuation: false, CompressPunctuation: true, AutoSpaceLatin: false, AutoSpaceDigits: false), - paragraphs[0].Format.Layout!.Breaking); - - // Word's defaults when nothing is said. - Assert.Equal(HParaBorder.None, paragraphs[1].Format.Layout!.Border); - Assert.Equal(new HLineBreaking(HLatinBreak.KeepWord, Kinsoku: true, OverflowPunctuation: true, CompressPunctuation: false, AutoSpaceLatin: true, AutoSpaceDigits: true), - paragraphs[1].Format.Layout!.Breaking); - } - - [Fact] - public void Docx_objects_keep_crop_rotation_mirroring_and_z_order_and_rows_their_split_setting() - { - var docx = new DocxBuilder { Settings = "" }; - using var image = new MagickImage(MagickColors.Orange, 40, 20); - var id = docx.Image(image.ToByteArray(MagickFormat.Png)); - var picture = Picture(id, 914400, 457200) - .Replace("", "", StringComparison.Ordinal) - .Replace("", "", StringComparison.Ordinal); - var position = "00"; - var shape = ""; - docx.Paragraph(P(Anchor(PictureUri, picture, 914400, 457200, position[..position.IndexOf("" + P(R("칸")) + "" + P(R("칸")) + ""); - - var document = ReadDocx(docx); - var inlines = Paragraphs(document).SelectMany(p => p.Inlines).ToList(); - - var read = inlines.OfType().Single(); - Assert.Equal(new HCrop(0.1, 0, 0.25, 0.05), read.Crop); - Assert.Equal((new HTransform(90, FlipHorizontal: true), 251660288), (read.Transform, read.ZOrder)); - Assert.Equal(new HTransform(-30), inlines.OfType().Single().Transform); - Assert.Equal([true, false], document.Sections[0].Blocks.OfType().Single().Rows.Select(r => r.CantSplit)); - Assert.Equal(new HKinsoku("ja-JP", ")」。", "(「"), Assert.Single(document.Kinsoku)); - } - - // ───────────────────────── Writers ───────────────────────── - - /// The parts of a written package (pictures by name only), to compare two writes. - private static Dictionary Parts(string package) - { - using var zip = ZipFile.OpenRead(package); - return zip.Entries.ToDictionary(e => e.FullName, e => - { - using var reader = new StreamReader(e.Open()); - return e.FullName.StartsWith("BinData/", StringComparison.Ordinal) || e.FullName.StartsWith("word/media/", StringComparison.Ordinal) ? "" : reader.ReadToEnd(); - }); - } - - [Fact] - public void Layout_detail_does_not_change_written_files() - { - HDocument Build(bool detail) - { - var slots = HLanguageSlots.All(new HSlotFormat("궁서", 80, 5, 90, 10)); - HCharFormat Char(HCharFormat format) => detail - ? format with { Layout = new HCharLayout(slots, new HWordFonts("A", "B", "C", "D"), 90, 100, 50) } - : format; - var paraLayout = new HParaLayout(new HLineHeight(HLineHeightRule.Multiple, 276, 1800), - new HParaBorder(HBorders.Grid, "#FFFF00", new HInsets(1, 2, 3, 4), true), new HLineBreaking(HLatinBreak.BreakWord, Kinsoku: false), true, true); - - var document = new HDocument { Title = "제목" }; - var section = new HSection(); - if (detail) - { - section.Grid = new HDocumentGrid(HGridKind.Lines, 1800); - var master = new HMasterPage(HMasterPageKind.Both); - master.Blocks.Add(new HParagraph()); - section.MasterPages.Add(master); - document.Kinsoku.Add(new HKinsoku(null, ")", "(")); - } - document.Sections.Add(section); - foreach (var bold in new[] { false, true, false }) - { - var paragraph = new HParagraph - { - Format = new HParaFormat(Align: HAlign.Justify, LineSpacing: new HLineSpacing(HLineSpacingKind.Percent, 150), Layout: detail ? paraLayout : null), - MarkFormat = Char(new HCharFormat(Size: 1100)), - Lines = detail ? new HLineLayout([new HLineSegment(0, 0, 1000, 1000, 850, 600, 0, 42520, 0)], Estimated: true) : null, - }; - paragraph.Inlines.Add(new HText(bold ? "굵게" : "보통", Char(new HCharFormat(Bold: bold, Size: 1100, Font: "Arial")))); - paragraph.Inlines.Add(new HText(" 이어서", Char(new HCharFormat(Size: 1100)))); - paragraph.Inlines.Add(new HShape(HShapeKind.Rectangle) - { - Width = 2000, - Height = 1000, - Anchor = new HAnchor("COLUMN", "LEFT", 0, "PARA", "TOP", 0, "SQUARE", true), - Transform = detail ? new HTransform(30, true) : null, - ZOrder = detail ? 5 : null, - }); - section.Blocks.Add(paragraph); - } - var table = new HTable { ColumnCount = 1, PageBreak = detail ? HTablePageBreak.None : null }; - var row = new HRow { CantSplit = detail }; - var cell = new HCell(); - cell.Blocks.Add(new HParagraph { Format = new HParaFormat(Layout: detail ? paraLayout : null) }); - row.Cells.Add(cell); - table.Rows.Add(row); - section.Blocks.Add(table); - return document; - } - - var dir = fx.NewFolder(); - string Write(string name, Action writer, bool detail) - { - var path = Path.Combine(dir, name); - writer(Build(detail), path); - return path; - } - Assert.Equal(Parts(Write("plain.hwpx", (d, p) => HwpxWriter.Write(d, p), false)), Parts(Write("detail.hwpx", (d, p) => HwpxWriter.Write(d, p), true))); - Assert.Equal(Parts(Write("plain.docx", DocxWriter.Write, false)), Parts(Write("detail.docx", DocxWriter.Write, true))); - Assert.Equal(PlainTextWriter.Write(Build(false)), PlainTextWriter.Write(Build(true))); - Assert.Equal(MarkdownWriter.Write(Build(false), _ => null), MarkdownWriter.Write(Build(true), _ => null)); - } - - [Fact] - public void Formats_compare_without_layout_detail_unless_asked() - { - var plain = new HCharFormat(Bold: true, Size: 1000); - var detailed = plain with { Layout = new HCharLayout(Scale: 80) }; - Assert.Equal(plain, detailed); - Assert.Equal(plain.GetHashCode(), detailed.GetHashCode()); - Assert.Equal(plain.ToString(), detailed.ToString()); - Assert.True(detailed.IsEmpty == plain.IsEmpty && new HCharFormat(Layout: new HCharLayout(Scale: 80)).IsEmpty); - Assert.False(plain.SameLayout(detailed)); - Assert.True(detailed.SameLayout(detailed with { Layout = new HCharLayout(Scale: 80) })); - - var paragraph = new HParaFormat(Align: HAlign.Center); - var withRule = paragraph with { Layout = new HParaLayout(new HLineHeight(HLineHeightRule.Multiple, 240)) }; - Assert.Equal(paragraph, withRule); - Assert.False(paragraph.SameLayout(withRule)); - } - - // ───────────────────────── Style resolver ───────────────────────── - - [Fact] - public void Style_resolver_gives_the_values_the_written_file_has() - { - // A document as the Markdown and HTML readers make it: most members left to the 한글 style. - var numbering = new HNumbering(); - numbering.Levels.Add(new HNumberingLevel("DIGIT", "^1.", 1, Bullet: false)); - var document = new HDocument(); - var section = new HSection(); - document.Sections.Add(section); - void Add(HParagraph paragraph, params HInline[] inlines) - { - paragraph.Inlines.AddRange(inlines); - section.Blocks.Add(paragraph); - } - Add(new HParagraph(), new HText("기본", default), new HText(" 굵게", new HCharFormat(Bold: true))); - Add(new HParagraph { HeadingLevel = 1 }, new HText("제목", default)); - Add(new HParagraph { List = new HListRef(numbering, 0, Numbered: true) }, new HText("항목", new HCharFormat(Font: "Arial", Spacing: -3))); - Add(new HParagraph { Format = new HParaFormat(Align: HAlign.Center, Left: 1000, FirstLine: -500, LineSpacing: new HLineSpacing(HLineSpacingKind.Fixed, 2000)) }, - new HText("가운데", new HCharFormat(Size: 1400, Color: "#123456", Superscript: true))); - var link = new HLink("https://example.com"); - link.Content.Add(new HText("링크", default)); - Add(new HParagraph(), link); - - var path = Path.Combine(fx.NewFolder(), "resolved.hwpx"); - HwpxWriter.Write(document, path); - var written = HwpxReader.Read(path, Path.Combine(Path.GetDirectoryName(path)!, "media")).Sections[0].Blocks.Cast().ToList(); - - var resolver = StyleResolver.FromTemplate(); - var paragraphs = section.Blocks.Cast().ToList(); - for (var i = 0; i < paragraphs.Count; i++) - { - var resolved = resolver.Paragraph(paragraphs[i]); - Assert.Equal(written[i].Format, resolved with { RightToLeft = null }); // HWPX has no right-to-left paragraphs - Assert.False(resolved.RightToLeft); - Assert.NotNull(resolved.Layout!.LineHeight); - Assert.NotNull(resolved.Layout.Breaking); - - var source = paragraphs[i].Inlines.SelectMany(x => x is HLink l ? l.Content.Select(c => (c, true)) : [(x, false)]).ToList(); - var read = written[i].Inlines.SelectMany(x => x is HLink l ? l.Content : [x]).Cast().ToList(); - Assert.Equal(source.Count, read.Count); - for (var j = 0; j < source.Count; j++) - { - var format = resolver.Character(((HText)source[j].Item1).Format, paragraphs[i], link: source[j].Item2); - Assert.Equal(read[j].Format, format); - Assert.True(format.Layout!.Slots!.Hangul.Font is not null && format.Layout.Slots.User.Ratio is not null); - } - } - Assert.Equal("Arial", resolver.Character(new HCharFormat(Font: "Arial"), paragraphs[0]).Layout!.Slots!.Other.Font); - Assert.Equal(-3, resolver.Character(new HCharFormat(Spacing: -3), paragraphs[0]).Layout!.Slots!.Symbol.Spacing); - } - - [Fact] - public void Style_resolver_keeps_the_rules_the_source_set() - { - var resolver = StyleResolver.FromTemplate(); - var word = new HParagraph - { - Format = new HParaFormat(LineSpacing: new HLineSpacing(HLineSpacingKind.Percent, 150), - Layout: new HParaLayout(new HLineHeight(HLineHeightRule.Multiple, 276), Breaking: new HLineBreaking(Kinsoku: false))), - }; - var resolved = resolver.Paragraph(word); - Assert.Equal(new HLineHeight(HLineHeightRule.Multiple, 276), resolved.Layout!.LineHeight); - Assert.False(resolved.Layout.Breaking!.Kinsoku); - Assert.NotNull(resolved.Layout.Breaking.Latin); // the rest from the style - - var fixedOnly = new HParagraph { Format = new HParaFormat(LineSpacing: new HLineSpacing(HLineSpacingKind.Fixed, 1800)) }; - Assert.Equal(new HLineHeight(HLineHeightRule.Exact, 1800), resolver.Paragraph(fixedOnly).Layout!.LineHeight); - - var narrow = new HCharFormat(Layout: new HCharLayout(HLanguageSlots.All(new HSlotFormat(Ratio: 70)))); - var slots = resolver.Character(narrow, fixedOnly).Layout!.Slots!; - Assert.Equal(70, slots.Latin.Ratio); - Assert.NotNull(slots.Latin.Font); // the font from the style - } -} diff --git a/tests/Filee.Engines.Tests/GeoHostileInputTests.cs b/tests/Filee.Engines.Tests/GeoHostileInputTests.cs index 8b141e415..95daaea96 100644 --- a/tests/Filee.Engines.Tests/GeoHostileInputTests.cs +++ b/tests/Filee.Engines.Tests/GeoHostileInputTests.cs @@ -7,7 +7,6 @@ using System.Text; using Filee.Core.Conversion; using Filee.Engines.Geo; -using Filee.Engines.Infrastructure; namespace Filee.Engines.Tests; diff --git a/tests/Filee.Engines.Tests/HwpxReaderTests.cs b/tests/Filee.Engines.Tests/HwpxReaderTests.cs index e9a83e845..9b44da775 100644 --- a/tests/Filee.Engines.Tests/HwpxReaderTests.cs +++ b/tests/Filee.Engines.Tests/HwpxReaderTests.cs @@ -636,7 +636,7 @@ private static void Write(ZipArchive zip, string name, string text, CompressionL /// one missing), a group, equation, compose, ruby text, notes, a chart with an OLE fallback, and in the second /// section other namespace prefixes. /// - internal static string HangulStylePackage(string folder) + private static string HangulStylePackage(string folder) { const string Namespaces = "xmlns:ha=\"http://www.hancom.co.kr/hwpml/2011/app\" xmlns:hp=\"http://www.hancom.co.kr/hwpml/2011/paragraph\" " +