diff --git a/Cargo.toml b/Cargo.toml index 1c1536f7d..e3e7ba349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,7 +63,7 @@ naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] libloading = "0.8" -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 393bce6b8..c11676a47 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5025,6 +5025,19 @@ plot = .export-pdf = تصدير PDF .fit-to-paper = ملاءمة للورق .layout-plots-the-current-sheet-using-the-selec = يطبع التخطيط الورقة الحالية باستخدام حجم الورق المحدد. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fa07928a7..1eb0a901d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5013,6 +5013,19 @@ plot = .export-pdf = Експортирай PDF .fit-to-paper = Побери в хартията .layout-plots-the-current-sheet-using-the-selec = Оформлението плотира текущия лист, използвайки избрания размер хартия. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 657eadb7a..5157d3f79 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5010,6 +5010,19 @@ plot = .export-pdf = Exportujte PDF .fit-to-paper = Přizpůsobit papíru .layout-plots-the-current-sheet-using-the-selec = Rozvržení vykreslí aktuální list pomocí zvolené velikosti papíru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Sloučit překrývající se čáry .object-lineweights = Tloušťky čar objektů .orientation = Orientace diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 2ee047680..9c4911cd8 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5011,6 +5011,19 @@ plot = .export-pdf = PDF exportieren .fit-to-paper = An Papier anpassen .layout-plots-the-current-sheet-using-the-selec = Das Layout plottet das aktuelle Blatt mit dem ausgewählten Papierformat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Zusammenführung überlappender Leitungen .object-lineweights = Objektliniengewichte .orientation = Ausrichtung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 77a87777c..edb6d9e6e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5013,6 +5013,19 @@ plot = .export-pdf = Εξαγωγή PDF .fit-to-paper = Προσαρμογή στο χαρτί .layout-plots-the-current-sheet-using-the-selec = Η επιλογή Διάταξη εκτυπώνει το τρέχον φύλλο με το επιλεγμένο μέγεθος χαρτιού. + .sheet-sizes-and-printable-areas-reported-by-th = Μεγέθη φύλλων και εκτυπώσιμες περιοχές όπως τα αναφέρει ο εκτυπωτής. + .custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού + .printable-margins = Εκτυπώσιμα περιθώρια + .enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος. + .paper-size = Μέγεθος χαρτιού + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ επιλογή(-ές) driver ορισμένες για αυτόν τον εκτυπωτή. + .printer-properties-printer = Ιδιότητες εκτυπωτή — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = Ο εκτυπωτής δεν αναφέρει επιλογές driver. + .could-not-read-the-printer-s-options-error = Αδυναμία ανάγνωσης των επιλογών του εκτυπωτή: __ocs_fmt_0__ + .reading-the-printer-s-options = Ανάγνωση των επιλογών του εκτυπωτή… + .printing-preferences-saved-for-printer = Οι προτιμήσεις εκτύπωσης αποθηκεύτηκαν για τον εκτυπωτή __ocs_fmt_0__. + .printing-preferences-unchanged = Οι προτιμήσεις εκτύπωσης δεν άλλαξαν. + .printer-properties = Ιδιότητες εκτυπωτή .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c767a4e9d..559a55db3 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5014,6 +5014,19 @@ plot = .export-pdf = Export PDF .fit-to-paper = Fit to paper .layout-plots-the-current-sheet-using-the-selec = Layout plots the current sheet using the selected paper size. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Merge overlapping lines .object-lineweights = Object lineweights .orientation = Orientation diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 7aebd0d2e..597e224bd 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5013,6 +5013,19 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar al papel .layout-plots-the-current-sheet-using-the-selec = La presentación traza la hoja actual con el tamaño de papel seleccionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionar líneas solapadas .object-lineweights = Grosores de línea de objetos .orientation = Orientación diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index b9404a17a..8c306f69b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5012,6 +5012,19 @@ plot = .export-pdf = Vie PDF .fit-to-paper = Sovita paperille .layout-plots-the-current-sheet-using-the-selec = Asettelu piirtää nykyisen arkin käyttämällä valittua paperikokoa. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Yhdistä päällekkäiset rivit .object-lineweights = Objektin viivan painot .orientation = Suuntautuminen diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index cae171e76..b9f0044c8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5013,6 +5013,19 @@ plot = .export-pdf = Exportation PDF .fit-to-paper = Ajuster au papier .layout-plots-the-current-sheet-using-the-selec = La présentation trace la feuille courante avec le format de papier sélectionné. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Fusionner les lignes qui se chevauchent .object-lineweights = Poids de ligne d'objet .orientation = Orientation diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 01edb1512..d799d20d0 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5001,6 +5001,19 @@ plot = .export-pdf = निर्यात PDF .fit-to-paper = कागज़ पर फ़िट करें .layout-plots-the-current-sheet-using-the-selec = लेआउट चयनित कागज़ आकार पर वर्तमान शीट प्लॉट करता है। + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 4b13d66b2..4e2820102 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5012,6 +5012,19 @@ plot = .export-pdf = PDF exportálása .fit-to-paper = Papírra illeszthető .layout-plots-the-current-sheet-using-the-selec = Az Elrendezés az aktuális lapot a kiválasztott papírmérettel ábrázolja. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Egyesítse az átfedő vonalakat .object-lineweights = Objektumvonalvastagságok .orientation = Tájékozódás diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index af9406dff..4cc8190c9 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5012,6 +5012,19 @@ plot = .export-pdf = Esporta PDF .fit-to-paper = Adatta alla carta .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Unisci linee sovrapposte .object-lineweights = Spessori linea degli oggetti .orientation = Orientamento diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 13ee9599a..ce01e9dda 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5001,6 +5001,19 @@ plot = .export-pdf = PDF 書き出し .fit-to-paper = 用紙にフィット .layout-plots-the-current-sheet-using-the-selec = 選択した用紙サイズで現在のシートを印刷します。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c93a7cc5d..e5a6fb083 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5012,6 +5012,19 @@ plot = .export-pdf = PDF 내보내기 .fit-to-paper = 종이에 맞추기 .layout-plots-the-current-sheet-using-the-selec = 레이아웃은 선택한 용지 크기를 사용하여 현재 시트를 플롯합니다. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 77d3c1c86..f22b95fe8 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5011,6 +5011,19 @@ plot = .export-pdf = Exporteren PDF .fit-to-paper = Aan papier aanpassen .layout-plots-the-current-sheet-using-the-selec = De lay-out plot het huidige vel op het geselecteerde papierformaat. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Overlaplijnen samenvoegen .object-lineweights = Objectlijndiktes .orientation = Oriëntatie diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index eb4ed4594..eb1491f71 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5012,6 +5012,19 @@ plot = .export-pdf = Eksportuj PDF .fit-to-paper = Dopasuj do papieru .layout-plots-the-current-sheet-using-the-selec = Układ drukuje bieżący arkusz przy użyciu wybranego rozmiaru papieru. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Połącz nakładające się linie .object-lineweights = Szerokości linii obiektu .orientation = Orientacja diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index bc244f710..43df0a1d6 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5013,6 +5013,19 @@ plot = .export-pdf = Exportar PDF .fit-to-paper = Ajustar ao papel .layout-plots-the-current-sheet-using-the-selec = Layout plota a folha atual usando o tamanho de papel selecionado. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Mesclar linhas sobrepostas .object-lineweights = Espessuras de linha dos objetos .orientation = Orientação diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 31b35ab9a..b89d617d4 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5014,6 +5014,19 @@ plot = .export-pdf = Экспорт PDF .fit-to-paper = Вписать в лист .layout-plots-the-current-sheet-using-the-selec = Лист печатается на выбранном формате бумаги. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 96a534d60..fb9160dcc 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4953,6 +4953,19 @@ plot = .export-pdf = PDF'ye Aktar .fit-to-paper = Kağıda sığdır .layout-plots-the-current-sheet-using-the-selec = Yerleşim, geçerli sayfayı seçilen kağıt boyutuyla çizer. + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = Çakışan çizgileri birleştir .object-lineweights = Nesne çizgi kalınlıkları .orientation = Yönelim diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 2f1bc69af..77a8561f4 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4996,6 +4996,19 @@ plot = .export-pdf = 导出 PDF .fit-to-paper = 布满图纸 .layout-plots-the-current-sheet-using-the-selec = 布局使用所选纸张尺寸打印当前图纸。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index a26fd898d..7925576ab 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5008,6 +5008,19 @@ plot = .export-pdf = 匯出 PDF .fit-to-paper = 布滿圖紙 .layout-plots-the-current-sheet-using-the-selec = 配置使用所選紙張尺寸列印目前圖紙。 + .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. + .paper-size = Paper size + .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. + .printer-properties-printer = Printer properties — __ocs_fmt_0__ + .this-printer-reports-no-driver-options = This printer reports no driver options. + .could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__ + .reading-the-printer-s-options = Reading the printer's options… + .printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__. + .printing-preferences-unchanged = Printing preferences unchanged. + .printer-properties = Printer properties .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 diff --git a/src/app/mod.rs b/src/app/mod.rs index c553441a0..1124dceb3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -943,8 +943,10 @@ pub(super) struct OpenCADStudio { mtext_click_count: u8, /// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None. plot_window: Option<(f64, f64, f64, f64)>, - plot_format: crate::io::paper_sizes::PaperSize, - plot_orientation: crate::io::paper_sizes::Orientation, + /// Sheet the Plot dialog opens with when the layout has no page setup; + /// updated from every plot / apply so the next dialog remembers it. + plot_paper: crate::io::paper_catalog::PaperSize, + plot_orientation: crate::io::paper_catalog::Orientation, /// Backing state for the full Plot / Print dialog. plot_dialog: crate::ui::window::plot::PlotDialogState, /// Snapshot of the dialog's settings taken when it opened, restored by the @@ -3320,10 +3322,6 @@ pub enum Message { PlotExport, /// Callback after the user picks (or cancels) the export path. PlotExportPath(Option), - /// User picked a paper size for the model-space window plot. - PlotFormat(crate::io::paper_sizes::PaperSize), - /// User picked a sheet orientation for the model-space window plot. - PlotOrientation(crate::io::paper_sizes::Orientation), /// Export the pending model-space plot window (from PLOTWINDOW) to PDF. PlotWindowExport, /// Callback after the user picks (or cancels) the window-export path. @@ -3947,8 +3945,8 @@ impl OpenCADStudio { mtext_click_off: 0, mtext_click_count: 0, plot_window: None, - plot_format: crate::io::paper_sizes::PaperSize::A4, - plot_orientation: crate::io::paper_sizes::Orientation::Landscape, + plot_paper: crate::io::paper_catalog::default_paper().clone(), + plot_orientation: crate::io::paper_catalog::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, plot_setup_template: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6d20f0430..a895f909a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -88,35 +88,119 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { use crate::io::pdf_export::{PdfPageInput, PlotContent}; -fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; - let orientation = if d.orientation == "Portrait" { +/// The dialog's sheet orientation as a catalogue value. +fn plot_dialog_orientation( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::Orientation { + use crate::io::paper_catalog::Orientation; + if d.orientation == "Portrait" { Orientation::Portrait } else { Orientation::Landscape - }; - let standard = match d.paper.as_str() { - "A3" => Some(PaperSize::A3), - "A2" => Some(PaperSize::A2), - "A1" => Some(PaperSize::A1), - "A0" => Some(PaperSize::A0), - "A4" => Some(PaperSize::A4), - "Letter" => Some(PaperSize::Letter), - "Legal" => Some(PaperSize::Legal), - "Tabloid" => Some(PaperSize::Tabloid), - _ => None, - }; - if let Some(paper) = standard { - return sheet_mm(paper, orientation); } - let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0); - let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0); - match orientation { - Orientation::Portrait => (short, long), - Orientation::Landscape => (long, short), +} + +/// The sheet the dialog names, or — when the name carries no size (an old +/// config, a bare driver name) — a custom sheet built from the dialog's stored +/// dimensions, so a drawing whose paper we cannot identify still plots at its +/// own size. +fn plot_dialog_paper( + d: &crate::ui::window::plot::PlotDialogState, +) -> crate::io::paper_catalog::PaperSize { + use crate::io::paper_catalog::{from_drawing, resolve, PaperSize, PaperUnits}; + if d.paper_width_mm > 0.0 && d.paper_height_mm > 0.0 { + from_drawing(&d.paper, d.paper_width_mm, d.paper_height_mm) + } else { + resolve(&d.paper) + .unwrap_or_else(|| PaperSize::custom(210.0, 297.0, PaperUnits::Millimeters)) } } +fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { + plot_dialog_paper(d).sheet_mm(plot_dialog_orientation(d)) +} + +/// Whether the dialog still names the sheet the stored settings carry. +fn paper_unchanged( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> bool { + !stored.paper_size.is_empty() + && crate::io::paper_catalog::from_drawing( + &stored.paper_size, + stored.paper_width, + stored.paper_height, + ) + .canonical + == d.paper +} + +/// The media name to store for the dialog's sheet. A drawing keeps its own +/// spelling of a sheet the user did not change — a system printer's `A4` or a +/// plotter's custom name must not turn into the catalogue's canonical name +/// just by passing through the dialog, or the source application would look the sheet up +/// under a name that driver never reported. Only a genuinely different +/// selection writes the canonical name. +fn paper_name_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> String { + if paper_unchanged(d, stored) { + stored.paper_size.clone() + } else { + d.paper.clone() + } +} + +/// The plot device the dialog currently targets. +fn plot_dialog_device(d: &crate::ui::window::plot::PlotDialogState) -> crate::io::plot_device::PlotDevice { + use crate::io::plot_device::PlotDevice; + if d.to_file { + PlotDevice::Pdf + } else { + match &d.printer { + Some(name) => PlotDevice::Printer(name.clone()), + None => PlotDevice::None, + } + } +} + +/// The plotter name to store. Same rule as the sheet: a device name the source application +/// wrote (its PDF driver, a plotter `.pc3` we plot to PDF for it) stays as it +/// is while the dialog still points at the same kind of device; a changed +/// device, or one of our own legacy labels, is written in the source application's spelling. +fn device_name_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> String { + use crate::io::plot_device::PlotDevice; + let device = plot_dialog_device(d); + let (stored_device, keep_stored) = PlotDevice::from_stored_name(&stored.printer_name); + if keep_stored && stored_device == device { + stored.printer_name.clone() + } else { + device.canonical_name() + } +} + +/// The unprintable margins to store: the drawing's own while neither the +/// sheet nor the device changed (they came from the source application's driver and we have +/// no better source), otherwise what our device reports for the new sheet — +/// the equivalent of the source application's `PAPERUPDATE`. +fn margins_for_settings( + d: &crate::ui::window::plot::PlotDialogState, + stored: &acadrust::objects::PlotSettings, +) -> acadrust::objects::PaperMargin { + use crate::io::plot_device::PlotDevice; + let device = plot_dialog_device(d); + let (stored_device, _) = PlotDevice::from_stored_name(&stored.printer_name); + if paper_unchanged(d, stored) && stored_device == device { + return stored.margins; + } + let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers); + acadrust::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top) +} + fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { let PlotContent { wires, hatches, wipeouts, images, .. } = content; let mut bounds = ( @@ -2916,9 +3000,16 @@ impl OpenCADStudio { .clone() .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) .unwrap_or_else(|| PlotSettings::new("")); + // Names and margins are decided against the stored settings before + // any field is overwritten. + let paper_size = paper_name_for_settings(&dialog, &ps); + let printer_name = device_name_for_settings(&dialog, &ps); + let margins = margins_for_settings(&dialog, &ps); + ps.paper_size = paper_size; + ps.printer_name = printer_name; + ps.margins = margins; ps.paper_width = w; ps.paper_height = h; - ps.paper_size = dialog.paper.clone(); ps.plot_type = match plot_area { "Window" => PlotType::Window, "Display" => PlotType::LastScreenDisplay, @@ -2955,11 +3046,6 @@ impl OpenCADStudio { ps.standard_scale_factor = factor; ps.flags.use_standard_scale = false; } - ps.printer_name = if dialog.to_file { - crate::ui::window::plot::OUT_PDF.into() - } else { - dialog.printer.clone().unwrap_or_default() - }; ps.current_style_sheet = dialog.style_name.clone(); ps.flags.scale_lineweights = dialog.scale_lw; ps.flags.print_lineweights = dialog.lineweights; @@ -3637,7 +3723,7 @@ impl OpenCADStudio { /// Open the full Plot / Print dialog, seeding its state from the active /// layout's plot settings and the printers found on the system. pub(super) fn on_plot_dialog_open(&mut self) -> Task { - use crate::io::paper_sizes::Orientation; + use crate::io::paper_catalog::Orientation; let previous = self.plot_dialog.clone(); let scales: Vec<(String, f64)> = self.tabs[self.active_tab] .scene @@ -3660,6 +3746,7 @@ impl OpenCADStudio { // Keep session-only choices while loading drawing fields from the layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); + d.default_printer = crate::io::plot_device::default_printer_name(); d.plot_styles = crate::io::plot_style::available_ctb_names(); d.scales = scales; d.plot_views = plot_views; @@ -3671,7 +3758,8 @@ impl OpenCADStudio { d.scale = one_to_one.clone(); } d.paper_space = paper_space; - d.paper = self.plot_format.label().to_string(); + d.paper = self.plot_paper.canonical.to_string(); + (d.paper_width_mm, d.paper_height_mm) = self.plot_paper.sheet_mm(self.plot_orientation); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", Orientation::Landscape => "Landscape", @@ -3742,7 +3830,188 @@ impl OpenCADStudio { } } self.active_modal = Some(crate::app::ModalKind::Plot); - Task::none() + self.request_printer_media() + } + + /// Keep the dialog's view of the selected printer's media current: use the + /// session cache when the printer was asked before, otherwise ask it in + /// the background and clear the stale answer meanwhile. PDF output and + /// the default printer list the catalogue instead. + fn request_printer_media(&mut self) -> Task { + let d = &mut self.plot_dialog; + let Some(printer) = (!d.to_file).then(|| d.printer.clone()).flatten() else { + d.printer_media = None; + return Task::none(); + }; + if let Some(caps) = crate::io::plot_device::cached_printer_capabilities(&printer) { + d.printer_media = Some(caps); + return Task::none(); + } + d.printer_media = None; + let name = printer.clone(); + background_task( + move || crate::io::plot_device::printer_capabilities(&printer), + move |caps| Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterMedia(name, caps)), + ) + } + + /// The inline custom-sheet editor of the Plot dialog. A sheet is added + /// to (or replaced in) the persisted list under its media name and + /// selected at once; the list is saved immediately so it survives a + /// crash the way a plotter configuration would. + fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) { + use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide}; + let d = &mut self.plot_dialog; + match msg { + C::Open => { + let paper = plot_dialog_paper(d); + let existing = d + .custom_papers + .iter() + .find(|custom| custom.canonical() == paper.canonical); + let margins = match existing { + Some(custom) => custom.margins_mm(), + None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers), + }; + let mut draft = CustomPaperDraft::from_paper(&paper, margins); + // A catalogue sheet is only the starting size; the new sheet + // gets its own name (the source application's generic one when left blank). + if existing.is_none() { + draft.name.clear(); + } + d.custom_editor = Some(draft); + } + C::Cancel => d.custom_editor = None, + C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value), + C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value), + C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value), + C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"), + C::Margin(side, value) => Self::edit_custom_draft(d, |draft| { + let index = match side { + MarginSide::Left => 0, + MarginSide::Bottom => 1, + MarginSide::Right => 2, + MarginSide::Top => 3, + }; + draft.margins[index] = value; + }), + C::Add => { + let Some(draft) = d.custom_editor.as_mut() else { + return; + }; + match draft.build() { + Ok(custom) => { + let canonical = custom.canonical(); + d.custom_papers.retain(|existing| existing.canonical() != canonical); + d.custom_papers.push(custom); + d.custom_editor = None; + d.paper = canonical; + let (w, h) = plot_dialog_sheet_mm(d); + d.paper_width_mm = w; + d.paper_height_mm = h; + self.save_config(); + } + Err(error) => draft.error = Some(error), + } + } + C::Remove => { + let selected = plot_dialog_paper(d).canonical.into_owned(); + let before = d.custom_papers.len(); + d.custom_papers.retain(|custom| custom.canonical() != selected); + if d.custom_papers.len() != before { + self.save_config(); + } + } + } + } + + fn edit_custom_draft( + d: &mut crate::ui::window::plot::PlotDialogState, + edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft), + ) { + if let Some(draft) = d.custom_editor.as_mut() { + edit(draft); + draft.error = None; + } + } + + /// Properties… for the selected printer. + /// + /// On CUPS platforms (Linux, macOS) the driver's options are listed in an + /// in-line editor and applied to every job for that printer through + /// `lp -o`. On Windows the driver's own document-properties sheet is + /// shown and its result stored as the printer's preferences, which the + /// print job honours. Without a named printer, or where the options + /// cannot be listed, the platform's printer settings open instead. + fn on_printer_properties(&mut self) -> Task { + let Some(printer) = self.plot_dialog.printer.clone().filter(|_| !self.plot_dialog.to_file) + else { + self.open_printer_settings_fallback(); + return Task::none(); + }; + #[cfg(target_os = "windows")] + { + return self.edit_windows_printer_preferences(printer); + } + #[allow(unreachable_code)] + { + self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft { + printer: printer.clone(), + options: None, + choices: Default::default(), + error: None, + }); + let name = printer.clone(); + background_task( + move || crate::io::print_to_printer::printer_options(&printer), + move |result| { + Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded( + name, result, + )) + }, + ) + } + } + + /// The driver's document-properties sheet, owned by the main window so + /// it sits on top of the plot dialog. + #[cfg(target_os = "windows")] + fn edit_windows_printer_preferences(&mut self, printer: String) -> Task { + let Some(window) = self.main_window else { + self.open_printer_settings_fallback(); + return Task::none(); + }; + iced::window::run(window, move |w| { + use iced::window::raw_window_handle::RawWindowHandle; + let owner = match w.window_handle().ok().map(|h| h.as_raw()) { + Some(RawWindowHandle::Win32(handle)) => handle.hwnd.get(), + _ => 0, + }; + match crate::io::print_to_printer::edit_printer_preferences(&printer, owner) { + Ok(true) => Ok(crate::tf!("Printing preferences saved for {printer}.").into_owned()), + Ok(false) => Ok(crate::t!("Printing preferences unchanged.").into_owned()), + Err(error) => Err(error), + } + }) + .then(|result| { + Task::done(Message::BackgroundIoFinished( + result.map(|message| message), + false, + )) + }) + } + + /// The platform's printer settings surface: a settings panel on Linux, + /// the printers control panel on Windows, System Settings on macOS. + fn open_printer_settings_fallback(&mut self) { + match crate::io::print_to_printer::open_printer_properties( + self.plot_dialog.printer.as_deref(), + ) { + Ok(()) => self + .command_line + .push_info(crate::t!("Opened printer properties.").as_ref()), + Err(error) => self.command_line.push_error(&error), + } } fn normalize_common_plot_dialog(&mut self) { @@ -3767,6 +4036,9 @@ impl OpenCADStudio { Task::none() } M::Printer(s) => { + // Driver choices belong to a printer; the editor closes when + // the target changes. + self.plot_dialog.printer_editor = None; if s == OUT_PDF { self.plot_dialog.to_file = true; } else if s == OUT_DEFAULT { @@ -3776,19 +4048,86 @@ impl OpenCADStudio { self.plot_dialog.to_file = false; self.plot_dialog.printer = Some(s); } + self.request_printer_media() + } + M::PrinterMedia(printer, caps) => { + let d = &mut self.plot_dialog; + if !d.to_file && d.printer.as_deref() == Some(printer.as_str()) { + d.printer_media = caps; + } Task::none() } - M::PrinterProperties => { - match crate::io::print_to_printer::open_printer_properties( - self.plot_dialog.printer.as_deref(), - ) { - Ok(()) => self - .command_line - .push_info(crate::t!("Opened printer properties.").as_ref()), - Err(error) => self.command_line.push_error(&error), + M::CustomPaper(msg) => { + self.on_custom_paper_msg(msg); + Task::none() + } + M::PrinterProperties => self.on_printer_properties(), + M::PrinterOptionsLoaded(printer, result) => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + if draft.printer == printer { + match result { + Ok(options) => { + // Start from the remembered choices, then the + // driver's defaults. + let remembered = self + .plot_dialog + .driver_options + .get(&printer) + .cloned() + .unwrap_or_default(); + let draft = self.plot_dialog.printer_editor.as_mut().unwrap(); + draft.choices = options + .iter() + .map(|option| { + let choice = remembered + .get(&option.key) + .filter(|c| option.choices.iter().any(|x| &x.keyword == *c)) + .cloned() + .unwrap_or_else(|| option.default.clone()); + (option.key.clone(), choice) + }) + .collect(); + draft.options = Some(options); + } + Err(error) => draft.error = Some(error), + } + } } Task::none() } + M::PrinterOptionSet(key, choice) => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + draft.choices.insert(key, choice); + } + Task::none() + } + M::PrinterOptionsReset => { + if let Some(draft) = self.plot_dialog.printer_editor.as_mut() { + if let Some(options) = &draft.options { + draft.choices = options + .iter() + .map(|option| (option.key.clone(), option.default.clone())) + .collect(); + } + } + Task::none() + } + M::PrinterOptionsApply => { + if let Some(draft) = self.plot_dialog.printer_editor.take() { + let overrides = draft.overrides(); + if overrides.is_empty() { + self.plot_dialog.driver_options.remove(&draft.printer); + } else { + self.plot_dialog.driver_options.insert(draft.printer.clone(), overrides); + } + self.save_config(); + } + Task::none() + } + M::PrinterOptionsCancel => { + self.plot_dialog.printer_editor = None; + Task::none() + } M::Paper(s) => { self.plot_dialog.paper = s; let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); @@ -3938,7 +4277,8 @@ impl OpenCADStudio { self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); } - Task::none() + // The setup may name a different printer. + self.request_printer_media() } M::SetCurrent => { self.apply_dialog_to_layout(); @@ -4100,10 +4440,11 @@ impl OpenCADStudio { let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; let d = &mut self.plot_dialog; d.to_file = true; - d.paper = "A4".into(); + let a4 = crate::io::paper_catalog::default_paper(); + d.paper = a4.canonical.to_string(); d.orientation = "Landscape".into(); - d.paper_width_mm = 297.0; - d.paper_height_mm = 210.0; + (d.paper_width_mm, d.paper_height_mm) = + a4.sheet_mm(crate::io::paper_catalog::Orientation::Landscape); d.area = if is_model { "Window".into() } else { @@ -4164,9 +4505,14 @@ impl OpenCADStudio { settings } }; + let paper_size = paper_name_for_settings(d, &ps); + let printer_name = device_name_for_settings(d, &ps); + let margins = margins_for_settings(d, &ps); + ps.paper_size = paper_size; + ps.printer_name = printer_name; + ps.margins = margins; ps.paper_width = w; ps.paper_height = h; - ps.paper_size = d.paper.clone(); ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, @@ -4212,11 +4558,6 @@ impl OpenCADStudio { ps.standard_scale_factor = factor; ps.flags.use_standard_scale = false; } - ps.printer_name = if d.to_file { - crate::ui::window::plot::OUT_PDF.into() - } else { - d.printer.clone().unwrap_or_default() - }; ps.current_style_sheet = d.style_name.clone(); ps.flags.scale_lineweights = d.scale_lw; ps.flags.print_lineweights = d.lineweights; @@ -4281,16 +4622,23 @@ impl OpenCADStudio { && active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); - let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); - if !matches!( - paper.as_str(), - "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" - ) && !ps.paper_size.is_empty() - { - paper = ps.paper_size.clone(); + // The stored media name wins (it is what the source application wrote); a drawing + // without a usable name is matched by size, and an unknown size keeps + // its own dimensions as a custom sheet. The sheet's orientation is the + // stored width/height order; the rotation below may flip it again. + let paper = crate::io::paper_catalog::from_drawing( + &ps.paper_size, + ps.paper_width, + ps.paper_height, + ); + let orient = if ps.paper_width >= ps.paper_height { + "Landscape" + } else { + "Portrait" } + .to_string(); let d = &mut self.plot_dialog; - d.paper = paper; + d.paper = paper.canonical.to_string(); d.paper_width_mm = ps.paper_width.max(1.0); d.paper_height_mm = ps.paper_height.max(1.0); d.orientation = orient; @@ -4365,12 +4713,19 @@ impl OpenCADStudio { _ => "Normal", } .into(); - if ps.printer_name.to_ascii_lowercase().contains("pdf") { - d.to_file = true; - d.printer = None; - } else { - d.to_file = false; - d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); + match crate::io::plot_device::PlotDevice::from_stored_name(&ps.printer_name).0 { + crate::io::plot_device::PlotDevice::Pdf => { + d.to_file = true; + d.printer = None; + } + crate::io::plot_device::PlotDevice::Printer(name) => { + d.to_file = false; + d.printer = Some(name); + } + crate::io::plot_device::PlotDevice::None => { + d.to_file = false; + d.printer = None; + } } d.style_name = style_name; d.apply_plot_styles = ps.flags.plot_plot_styles; @@ -4381,25 +4736,9 @@ impl OpenCADStudio { /// Copy transient paper/scale choices out of the dialog without changing /// the active layout. fn sync_dialog_plot_runtime(&mut self) { - use crate::io::paper_sizes::{Orientation, PaperSize}; - let d = self.plot_dialog.clone(); - let paper = match d.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - "Letter" => PaperSize::Letter, - "Legal" => PaperSize::Legal, - "Tabloid" => PaperSize::Tabloid, - _ => PaperSize::A4, - }; - let orient = if d.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - self.plot_format = paper; - self.plot_orientation = orient; + let d = &self.plot_dialog; + self.plot_paper = plot_dialog_paper(d); + self.plot_orientation = plot_dialog_orientation(d); } fn apply_dialog_to_layout(&mut self) { @@ -4519,6 +4858,12 @@ impl OpenCADStudio { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), quality: Some(d.quality.clone()), + driver_options: d + .printer + .as_ref() + .and_then(|printer| d.driver_options.get(printer)) + .map(|options| options.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(), } } @@ -4651,7 +4996,7 @@ impl OpenCADStudio { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { - use crate::io::paper_sizes::{window_to_sheet, PlotScale}; + use crate::io::paper_catalog::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { @@ -4811,3 +5156,294 @@ impl OpenCADStudio { ) } } + +#[cfg(test)] +mod plot_paper_tests { + use crate::app::{Message, OpenCADStudio}; + use crate::ui::window::plot::PlotDlgMsg; + + /// A drawing on `Layout1` whose page setup names its sheet the way a + /// Windows printer driver does — a bare `A4` with landscape dimensions. + fn app_with_printer_named_sheet() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let _ = app.update(Message::LayoutSwitch("Layout1".into())); + let i = app.active_tab; + let mut ps = app.tabs[i] + .scene + .plot_settings_for("Layout1") + .expect("a fresh layout carries plot settings"); + ps.paper_size = "A4".into(); + ps.paper_width = 297.0; + ps.paper_height = 210.0; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + app + } + + fn layout_paper(app: &OpenCADStudio) -> (String, f64, f64) { + let ps = app.tabs[app.active_tab] + .scene + .plot_settings_for("Layout1") + .unwrap(); + (ps.paper_size, ps.paper_width, ps.paper_height) + } + + #[test] + fn dialog_shows_the_catalogue_sheet_for_a_driver_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!(app.plot_dialog.orientation, "Landscape"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (297.0, 210.0)); + } + + #[test] + fn applying_an_unchanged_sheet_keeps_the_drawing_media_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "A4", "the driver's own spelling must survive the dialog"); + assert_eq!((w, h), (297.0, 210.0)); + } + + #[test] + fn picking_another_sheet_writes_the_canonical_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (420.0, 297.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let (name, w, h) = layout_paper(&app); + assert_eq!(name, "ISO_A3_(297.00_x_420.00_MM)"); + assert_eq!((w, h), (420.0, 297.0)); + // The next dialog remembers the sheet the user chose. + assert_eq!(app.plot_paper.canonical, "ISO_A3_(297.00_x_420.00_MM)"); + } + + #[test] + fn pdf_output_is_stored_as_the_source_app_pdf_driver() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + // A changed device takes the PDF driver's printable area for the sheet. + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); + } + + #[test] + fn legacy_pdf_label_is_upgraded_on_save() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "Save to PDF file…".into(); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.to_file, "the legacy label still means PDF output"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "DWG To PDF.pc3"); + } + + #[test] + fn a_plotter_configuration_from_source_app_is_preserved_with_its_margins() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "DWF6 ePlot.pc3".into(); + ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into(); + ps.margins = acadrust::objects::PaperMargin::new(5.793749, 17.793753, 5.793744, 17.793747); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert!(app.plot_dialog.to_file, "an unknown plotter plots to PDF here"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let after = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(after.printer_name, "DWF6 ePlot.pc3"); + assert_eq!(after.margins, ps.margins, "the source application's driver margins survive an unchanged setup"); + } + + #[test] + fn changing_the_sheet_updates_the_printable_area() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.printer_name = "DWG To PDF.pc3".into(); + ps.paper_size = "ISO_A4_(210.00_x_297.00_MM)".into(); + ps.margins = acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0); + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_full_bleed_A4_(210.00_x_297.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.paper_size, "ISO_full_bleed_A4_(210.00_x_297.00_MM)"); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(0.0, 1.0, 0.0, 1.0)); + let _ = app.on_plot_dlg(PlotDlgMsg::Paper("ISO_A3_(297.00_x_420.00_MM)".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(5.0, 17.0, 6.0, 18.0)); + assert_eq!((ps.paper_width, ps.paper_height), (420.0, 297.0)); + } + + #[test] + fn a_selected_printer_is_stored_by_name() { + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + assert!(!app.plot_dialog.to_file); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.printer_name, "OCS Test Printer"); + // Reopening resolves the stored name back to the same printer. + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.printer.as_deref(), Some("OCS Test Printer")); + // A media answer for a printer that is no longer selected is ignored. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let caps = std::sync::Arc::new(crate::io::plot_device::PrinterCapabilities::default()); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterMedia("OCS Test Printer".into(), Some(caps))); + assert!(app.plot_dialog.printer_media.is_none()); + } + + #[test] + fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() { + use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let custom = |m: C| PlotDlgMsg::CustomPaper(m); + let _ = app.on_plot_dlg(custom(C::Open)); + // Seeded from the selected ISO A4 on the PDF driver, nameless. + let draft = app.plot_dialog.custom_editor.clone().expect("editor open"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("210", "297")); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + assert!(draft.name.is_empty()); + let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into()))); + let _ = app.on_plot_dlg(custom(C::Width("1500".into()))); + let _ = app.on_plot_dlg(custom(C::Height("609.6".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert!(app.plot_dialog.custom_editor.is_none(), "Add closes the editor"); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + assert_eq!(app.current_config().plot.custom_papers.len(), 1, "persisted in the plot config"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!((ps.paper_width, ps.paper_height), (1500.0, 609.6)); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0)); + // Re-adding the same size replaces the definition instead of duplicating it. + let _ = app.on_plot_dlg(custom(C::Open)); + assert_eq!(app.plot_dialog.custom_editor.as_ref().map(|d| d.name.as_str()), Some("Roll 24")); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert_eq!(app.plot_dialog.custom_papers.len(), 1); + assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0); + // Remove drops the definition; the sheet itself stays selectable. + let _ = app.on_plot_dlg(custom(C::Remove)); + assert!(app.plot_dialog.custom_papers.is_empty()); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + } + + #[test] + fn an_invalid_custom_sheet_is_refused_with_a_reason() { + use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open)); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into()))); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add)); + let draft = app.plot_dialog.custom_editor.as_ref().expect("editor stays open"); + assert_eq!(draft.error, Some(CustomPaperError::Size)); + assert!(app.plot_dialog.custom_papers.is_empty()); + // Typing again clears the message. + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into()))); + assert!(app.plot_dialog.custom_editor.as_ref().unwrap().error.is_none()); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel)); + assert!(app.plot_dialog.custom_editor.is_none()); + } + + #[test] + fn driver_options_are_edited_remembered_and_sent_with_the_job() { + use crate::io::print_to_printer::parse_lpoptions; + const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\ +ColorModel/Print Color Mode: *RGB Gray\n\ +cupsPrintQuality/Print Quality: *Normal High\n"; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let draft = app.plot_dialog.printer_editor.as_ref().expect("editor opens"); + assert_eq!(draft.printer, "OCS Test Printer"); + assert!(draft.options.is_none(), "options load in the background"); + // An answer for another printer is ignored; the right one fills the editor. + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "Other".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + assert!(app.plot_dialog.printer_editor.as_ref().unwrap().options.is_none()); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + let draft = app.plot_dialog.printer_editor.as_ref().unwrap(); + assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3)); + assert_eq!(draft.choices["ColorModel"], "RGB"); + assert!(draft.overrides().is_empty(), "defaults are not overrides"); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("ColorModel".into(), "Gray".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("cupsPrintQuality".into(), "High".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply); + assert!(app.plot_dialog.printer_editor.is_none(), "Apply closes the editor"); + let remembered = &app.plot_dialog.driver_options["OCS Test Printer"]; + assert_eq!(remembered.len(), 2); + assert_eq!(remembered["ColorModel"], "Gray"); + // The job carries exactly those overrides… + let opts = app.plot_print_options(&app.plot_dialog); + assert_eq!( + opts.driver_options, + vec![("ColorModel".to_string(), "Gray".to_string()), ("cupsPrintQuality".to_string(), "High".to_string())] + ); + // …they survive in the config, and another printer gets none of them. + assert_eq!(app.current_config().plot.driver_options.len(), 1); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into())); + assert!(app.plot_print_options(&app.plot_dialog).driver_options.is_empty()); + // Reopening the editor starts from the remembered choices; Reset then + // Apply forgets them. + let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into())); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Ok(parse_lpoptions(LPOPTIONS)), + )); + assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"], "Gray"); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply); + assert!(app.plot_dialog.driver_options.get("OCS Test Printer").is_none()); + // A failed listing is reported inside the editor, not lost. + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded( + "OCS Test Printer".into(), + Err("no cups".into()), + )); + assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().error.as_deref(), Some("no cups")); + let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel); + assert!(app.plot_dialog.printer_editor.is_none()); + } + + #[test] + fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() { + let mut app = app_with_printer_named_sheet(); + let i = app.active_tab; + let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap(); + ps.paper_size = "Roll_24_(609.60_x_1500.00_MM)".into(); + ps.paper_width = 1500.0; + ps.paper_height = 609.6; + assert!(app.tabs[i].scene.set_layout_plot_settings("Layout1", &ps)); + let _ = app.on_plot_dialog_open(); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + assert_eq!(layout_paper(&app).0, "Roll_24_(609.60_x_1500.00_MM)"); + } +} diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 95155c21e..92b60e0cc 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8512,14 +8512,6 @@ impl OpenCADStudio { Message::PlotExportPath(None) => Task::none(), Message::PlotExportPath(Some(path)) => self.on_plot_export_path_some(path), - Message::PlotFormat(f) => { - self.plot_format = f; - Task::none() - } - Message::PlotOrientation(o) => { - self.plot_orientation = o; - Task::none() - } Message::PlotWindowExport => { let i = self.active_tab; let stem = self.tabs[i] diff --git a/src/app/update/util.rs b/src/app/update/util.rs index cb78c36ab..8115d3f40 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,27 +62,6 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -/// Infer an A-series label when dimensions match; otherwise retain the size. -pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { - use crate::io::paper_sizes::PaperSize; - let orient = if w >= h { "Landscape" } else { "Portrait" }; - let (short, long) = if w <= h { (w, h) } else { (h, w) }; - let mut best = ("A4".to_string(), f64::INFINITY); - for p in PaperSize::ALL { - let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph - let err = (pw - short).abs() + (ph - long).abs(); - if err < best.1 { - best = (p.label().to_string(), err); - } - } - let label = if best.1 <= 2.0 { - best.0 - } else { - format!("{w:.2} × {h:.2} mm") - }; - (label, orient.to_string()) -} - pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { if s == "Fit" { return (1.0, 1.0); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 993c7ec9d..9b0f5d305 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -64,8 +64,8 @@ impl OpenCADStudio { ) -> Element<'s, Message> { sized_flow( extra, - 760, - 540, + 940, + 620, |flow| { crate::ui::window::plot::view_window( &self.plot_dialog, diff --git a/src/io/mod.rs b/src/io/mod.rs index 55ae70b1f..8bc9c7506 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -20,7 +20,8 @@ pub mod xref_model; pub mod linetypes; pub mod patterns; pub mod update_check; -pub mod paper_sizes; +pub mod paper_catalog; +pub mod plot_device; pub mod thumbnail; #[cfg(target_arch = "wasm32")] mod web_worker; diff --git a/src/io/paper_catalog.rs b/src/io/paper_catalog.rs new file mode 100644 index 000000000..1842f4b3d --- /dev/null +++ b/src/io/paper_catalog.rs @@ -0,0 +1,867 @@ +//! Paper catalogue with the source application-compatible media names. +//! +//! the source application stores a layout's paper as the plotter driver's *canonical media +//! name* — `ISO_A4_(210.00_x_297.00_MM)`, `ANSI_B_(11.00_x_17.00_Inches)`, +//! `ARCH_full_bleed_D_(24.00_x_36.00_Inches)` — always with the portrait +//! dimensions; landscape is expressed through the plot rotation. A drawing +//! saved here has to carry those exact names so the source application finds the media in its +//! `DWG To PDF.pc3` list instead of silently falling back to a default sheet, +//! and a name that arrives from the source application (including a custom size this catalogue +//! has never seen) must still resolve to its dimensions and go back out +//! untouched. The name itself encodes the dimensions, which is what makes the +//! second half possible: [`PaperSize::parse_canonical`] never needs a table. + +use std::borrow::Cow; +use std::sync::OnceLock; + +/// Unit the media is defined in. the source application keeps ISO/JIS sheets in millimetres +/// and ANSI/ARCH sheets in inches, and the unit is part of the media name. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)] +pub enum PaperUnits { + Millimeters, + Inches, +} + +impl PaperUnits { + pub const MM_PER_INCH: f64 = 25.4; + + pub fn to_mm(self, value: f64) -> f64 { + match self { + PaperUnits::Millimeters => value, + PaperUnits::Inches => value * Self::MM_PER_INCH, + } + } + + pub fn from_mm(self, value: f64) -> f64 { + match self { + PaperUnits::Millimeters => value, + PaperUnits::Inches => value / Self::MM_PER_INCH, + } + } + + /// The unit token the source application writes inside the media name. + fn canonical_suffix(self) -> &'static str { + match self { + PaperUnits::Millimeters => "MM", + PaperUnits::Inches => "Inches", + } + } + + fn from_canonical_suffix(token: &str) -> Option { + if token.eq_ignore_ascii_case("MM") { + Some(PaperUnits::Millimeters) + } else if token.eq_ignore_ascii_case("Inches") || token.eq_ignore_ascii_case("Inch") { + Some(PaperUnits::Inches) + } else { + None + } + } + + /// Short unit label for display (`mm` / `in`). + pub fn short_label(self) -> &'static str { + match self { + PaperUnits::Millimeters => "mm", + PaperUnits::Inches => "in", + } + } +} + +/// The sheet family, used to group the picker and to prefer a standard sheet +/// over its full-bleed twin when matching by dimensions. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub enum PaperSeries { + IsoA, + IsoB, + Ansi, + Arch, + Jis, + /// Letter / Legal / Tabloid under their US names, and any user-defined sheet. + Other, +} + +/// the source application offers three printable-area variants of most sheets: the standard +/// one with driver margins, *full bleed* with zero margins, and *expand* +/// (the driver's maximum printable area). The sheet dimensions are identical; +/// only the media name and the margins differ. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)] +pub enum PaperVariant { + Standard, + FullBleed, + Expand, +} + +impl PaperVariant { + /// The infix the source application places between the series and the sheet letter. + fn canonical_infix(self) -> &'static str { + match self { + PaperVariant::Standard => "", + PaperVariant::FullBleed => "full_bleed_", + PaperVariant::Expand => "expand_", + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum Orientation { + Portrait, + Landscape, +} + +/// Unprintable margins of a sheet, in the order the source application stores them. The unit +/// is whatever the context says — millimetres in a drawing, the sheet's own +/// unit in a user-defined size. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Margins { + pub left: f64, + pub bottom: f64, + pub right: f64, + pub top: f64, +} + +impl Margins { + pub const ZERO: Margins = Margins { + left: 0.0, + bottom: 0.0, + right: 0.0, + top: 0.0, + }; + + pub const fn uniform(value: f64) -> Self { + Margins { + left: value, + bottom: value, + right: value, + top: value, + } + } + + /// The same margins expressed in millimetres when they were given in + /// `units`. + pub fn to_mm(self, units: PaperUnits) -> Self { + Margins { + left: units.to_mm(self.left), + bottom: units.to_mm(self.bottom), + right: units.to_mm(self.right), + top: units.to_mm(self.top), + } + } +} + +/// A sheet the user defined, kept in the application settings the way +/// the source application keeps custom sizes in a plotter configuration: a name, the +/// dimensions in the sheet's own unit, and the printable margins. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct CustomPaper { + /// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name. + pub name: String, + pub width: f64, + pub height: f64, + pub units: PaperUnits, + /// Unprintable margins in `units`. + pub margins: Margins, +} + +impl Default for CustomPaper { + fn default() -> Self { + Self { + name: String::new(), + width: 0.0, + height: 0.0, + units: PaperUnits::Millimeters, + margins: Margins::ZERO, + } + } +} + +impl CustomPaper { + /// The catalogue sheet this definition describes. + pub fn paper(&self) -> PaperSize { + PaperSize::custom_named(&self.name, self.width, self.height, self.units) + } + + pub fn canonical(&self) -> String { + self.paper().canonical.into_owned() + } + + pub fn margins_mm(&self) -> Margins { + self.margins.to_mm(self.units) + } +} + +/// One sheet of the catalogue, or a sheet reconstructed from a media name. +#[derive(Clone, PartialEq, Debug)] +pub struct PaperSize { + /// the source application canonical media name, e.g. `ISO_A4_(210.00_x_297.00_MM)`. This is + /// the value written to `PlotSettings::paper_size` and the key the plot + /// dialog stores. + pub canonical: Cow<'static, str>, + /// Human name without dimensions: `ISO A4`, `ANSI full bleed B`. + pub label: Cow<'static, str>, + pub series: PaperSeries, + pub variant: PaperVariant, + /// Portrait width in `units` (width ≤ height). + pub width: f64, + /// Portrait height in `units`. + pub height: f64, + pub units: PaperUnits, +} + +impl PaperSize { + /// Portrait dimensions in millimetres. + pub fn portrait_mm(&self) -> (f64, f64) { + (self.units.to_mm(self.width), self.units.to_mm(self.height)) + } + + /// Sheet dimensions in millimetres for the given orientation. + pub fn sheet_mm(&self, orientation: Orientation) -> (f64, f64) { + let (w, h) = self.portrait_mm(); + match orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + } + } + + /// Picker text: the human name followed by the dimensions in the sheet's + /// own unit — `ISO A4 (210 × 297 mm)`, `ANSI B (11.00 × 17.00 in)`. + pub fn display(&self) -> String { + format!( + "{} ({} × {} {})", + self.label, + format_dimension(self.width, self.units), + format_dimension(self.height, self.units), + self.units.short_label() + ) + } + + /// A user-defined sheet, named the way the source application names custom media created + /// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The + /// dimensions are normalised to portrait so the name stays canonical. + pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self { + let label = match units { + PaperUnits::Millimeters => "UserDefinedMetric", + PaperUnits::Inches => "UserDefinedInches", + }; + Self::custom_named(label, width, height, units) + } + + /// A user-defined sheet under its own name, the way a plotter + /// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`). + /// A blank name falls back to the source application's generic one; spaces become + /// underscores in the media name and parentheses are dropped so the name + /// stays parseable. + pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self { + let (width, height) = if width <= height { + (width, height) + } else { + (height, width) + }; + let cleaned: String = name + .trim() + .chars() + .filter(|c| !matches!(c, '(' | ')')) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if cleaned.is_empty() { + return Self::custom(width, height, units); + } + Self { + canonical: Cow::Owned(canonical_name( + &cleaned.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(cleaned), + series: PaperSeries::Other, + variant: PaperVariant::Standard, + width, + height, + units, + } + } + + /// Rebuild a sheet from any media name that follows the source application's + /// `_(_x__)` convention, whether or not the + /// catalogue knows it. The dialog form with spaces (`ISO A4 (210.00 x + /// 297.00 MM)`) is accepted too. Returns `None` for names without an + /// embedded dimension group (a bare printer paper name such as `A4`, or a + /// raster `Pixels` size). + pub fn parse_canonical(name: &str) -> Option { + let name = name.trim(); + let open = name.rfind('(')?; + let close = name[open..].find(')')? + open; + let dims = name[open + 1..close].trim_matches(['_', ' ']); + let raw_label = name[..open].trim_matches(['_', ' ']); + if raw_label.is_empty() { + return None; + } + // `210.00_x_297.00_MM`, or `210.00 x 297.00 MM` from a dialog string. + let normalized: String = dims.replace(' ', "_"); + let mut parts = normalized.split('_').filter(|part| !part.is_empty()); + let width: f64 = parts.next()?.parse().ok()?; + if !parts.next()?.eq_ignore_ascii_case("x") { + return None; + } + let height: f64 = parts.next()?.parse().ok()?; + let units = PaperUnits::from_canonical_suffix(parts.next()?)?; + if parts.next().is_some() + || width <= 0.0 + || height <= 0.0 + || !width.is_finite() + || !height.is_finite() + { + return None; + } + let label = raw_label.replace('_', " "); + let (series, variant) = classify_label(&label); + let (portrait_width, portrait_height) = if width <= height { + (width, height) + } else { + (height, width) + }; + Some(Self { + canonical: Cow::Owned(canonical_name( + &raw_label.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(label), + series, + variant, + width: portrait_width, + height: portrait_height, + units, + }) + } +} + +fn format_dimension(value: f64, units: PaperUnits) -> String { + match units { + // Millimetre sheets are whole numbers in every standard; keep the + // fraction only when a custom size needs it. + PaperUnits::Millimeters if (value - value.round()).abs() < 1e-6 => { + format!("{}", value.round() as i64) + } + PaperUnits::Millimeters => format!("{value:.1}"), + PaperUnits::Inches => format!("{value:.2}"), + } +} + +/// `_(_x__)` exactly as the source application spells it: two decimals, +/// portrait order. +fn canonical_name(prefix: &str, width: f64, height: f64, units: PaperUnits) -> String { + format!( + "{prefix}_({width:.2}_x_{height:.2}_{})", + units.canonical_suffix() + ) +} + +/// Series and variant from a human label (`ISO full bleed A4`, `ARCH expand D`). +fn classify_label(label: &str) -> (PaperSeries, PaperVariant) { + let lower = label.to_ascii_lowercase(); + let variant = if lower.contains("full bleed") { + PaperVariant::FullBleed + } else if lower.contains("expand") { + PaperVariant::Expand + } else { + PaperVariant::Standard + }; + let series = if lower.starts_with("iso b") { + PaperSeries::IsoB + } else if lower.starts_with("iso") { + PaperSeries::IsoA + } else if lower.starts_with("ansi") { + PaperSeries::Ansi + } else if lower.starts_with("arch") { + PaperSeries::Arch + } else if lower.starts_with("jis") { + PaperSeries::Jis + } else { + PaperSeries::Other + }; + (series, variant) +} + +// ── Catalogue ───────────────────────────────────────────────────────────── + +/// Compact source row: series prefix as the source application writes it, sheet letter, +/// portrait width/height, units, and whether the source application ships full-bleed / +/// expand twins of it in `DWG To PDF.pc3`. +struct Row { + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + width: f64, + height: f64, + units: PaperUnits, + twins: bool, +} + +const fn mm( + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + w: f64, + h: f64, + twins: bool, +) -> Row { + Row { + series, + prefix, + sheet, + width: w, + height: h, + units: PaperUnits::Millimeters, + twins, + } +} + +const fn inch( + series: PaperSeries, + prefix: &'static str, + sheet: &'static str, + w: f64, + h: f64, + twins: bool, +) -> Row { + Row { + series, + prefix, + sheet, + width: w, + height: h, + units: PaperUnits::Inches, + twins, + } +} + +/// The sheets the source application's `DWG To PDF.pc3` offers, in its display order, plus +/// the ISO B / JIS B series that other drivers add and the US names system +/// printers report. +const ROWS: &[Row] = &[ + mm(PaperSeries::IsoA, "ISO", "A0", 841.0, 1189.0, true), + mm(PaperSeries::IsoA, "ISO", "A1", 594.0, 841.0, true), + mm(PaperSeries::IsoA, "ISO", "A2", 420.0, 594.0, true), + mm(PaperSeries::IsoA, "ISO", "A3", 297.0, 420.0, true), + mm(PaperSeries::IsoA, "ISO", "A4", 210.0, 297.0, true), + mm(PaperSeries::IsoA, "ISO", "A5", 148.0, 210.0, false), + mm(PaperSeries::IsoB, "ISO", "B0", 1000.0, 1414.0, false), + mm(PaperSeries::IsoB, "ISO", "B1", 707.0, 1000.0, false), + mm(PaperSeries::IsoB, "ISO", "B2", 500.0, 707.0, false), + mm(PaperSeries::IsoB, "ISO", "B3", 353.0, 500.0, false), + mm(PaperSeries::IsoB, "ISO", "B4", 250.0, 353.0, false), + mm(PaperSeries::IsoB, "ISO", "B5", 176.0, 250.0, false), + inch(PaperSeries::Ansi, "ANSI", "A", 8.5, 11.0, true), + inch(PaperSeries::Ansi, "ANSI", "B", 11.0, 17.0, true), + inch(PaperSeries::Ansi, "ANSI", "C", 17.0, 22.0, true), + inch(PaperSeries::Ansi, "ANSI", "D", 22.0, 34.0, true), + inch(PaperSeries::Ansi, "ANSI", "E", 34.0, 44.0, true), + inch(PaperSeries::Arch, "ARCH", "A", 9.0, 12.0, true), + inch(PaperSeries::Arch, "ARCH", "B", 12.0, 18.0, true), + inch(PaperSeries::Arch, "ARCH", "C", 18.0, 24.0, true), + inch(PaperSeries::Arch, "ARCH", "D", 24.0, 36.0, true), + inch(PaperSeries::Arch, "ARCH", "E", 36.0, 48.0, true), + inch(PaperSeries::Arch, "ARCH", "E1", 30.0, 42.0, true), + mm(PaperSeries::Jis, "JIS", "B0", 1030.0, 1456.0, false), + mm(PaperSeries::Jis, "JIS", "B1", 728.0, 1030.0, false), + mm(PaperSeries::Jis, "JIS", "B2", 515.0, 728.0, false), + mm(PaperSeries::Jis, "JIS", "B3", 364.0, 515.0, false), + mm(PaperSeries::Jis, "JIS", "B4", 257.0, 364.0, false), + mm(PaperSeries::Jis, "JIS", "B5", 182.0, 257.0, false), + inch(PaperSeries::Other, "Letter", "", 8.5, 11.0, false), + inch(PaperSeries::Other, "Legal", "", 8.5, 14.0, false), + inch(PaperSeries::Other, "Tabloid", "", 11.0, 17.0, false), +]; + +fn build_catalog() -> Vec { + let mut sizes = Vec::with_capacity(ROWS.len() * 3); + for row in ROWS { + let variants: &[PaperVariant] = if row.twins { + &[ + PaperVariant::Standard, + PaperVariant::FullBleed, + PaperVariant::Expand, + ] + } else { + &[PaperVariant::Standard] + }; + for &variant in variants { + // `ISO_full_bleed_A4`, `ANSI_expand_B`, `Letter`. + let prefix = if row.sheet.is_empty() { + row.prefix.to_string() + } else { + format!("{}_{}{}", row.prefix, variant.canonical_infix(), row.sheet) + }; + sizes.push(PaperSize { + canonical: Cow::Owned(canonical_name(&prefix, row.width, row.height, row.units)), + label: Cow::Owned(prefix.replace('_', " ")), + series: row.series, + variant, + width: row.width, + height: row.height, + units: row.units, + }); + } + } + sizes +} + +/// Every catalogued sheet in picker order. +pub fn catalog() -> &'static [PaperSize] { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG.get_or_init(build_catalog) +} + +/// The sheet a fresh drawing or a page setup without one falls back to. +pub fn default_paper() -> &'static PaperSize { + catalog() + .iter() + .find(|paper| paper.canonical == "ISO_A4_(210.00_x_297.00_MM)") + .expect("catalogue always carries ISO A4") +} + +/// Short names a system printer driver, an older Open CAD Studio config or a +/// user at the command line may spell a sheet with, mapped onto the canonical +/// sheet. `Ledger` is the landscape name of Tabloid; the sheet is the same. +const ALIASES: &[(&str, &str)] = &[ + ("A0", "ISO_A0_(841.00_x_1189.00_MM)"), + ("A1", "ISO_A1_(594.00_x_841.00_MM)"), + ("A2", "ISO_A2_(420.00_x_594.00_MM)"), + ("A3", "ISO_A3_(297.00_x_420.00_MM)"), + ("A4", "ISO_A4_(210.00_x_297.00_MM)"), + ("A5", "ISO_A5_(148.00_x_210.00_MM)"), + ("B4", "ISO_B4_(250.00_x_353.00_MM)"), + ("B5", "ISO_B5_(176.00_x_250.00_MM)"), + ("Letter", "Letter_(8.50_x_11.00_Inches)"), + ("US Letter", "Letter_(8.50_x_11.00_Inches)"), + ("Legal", "Legal_(8.50_x_14.00_Inches)"), + ("US Legal", "Legal_(8.50_x_14.00_Inches)"), + ("Tabloid", "Tabloid_(11.00_x_17.00_Inches)"), + ("Ledger", "Tabloid_(11.00_x_17.00_Inches)"), +]; + +/// Resolve any spelling of a sheet: an exact canonical name (case-insensitive, +/// underscores or spaces), a driver/config alias such as `A4` or `Letter`, or +/// an unknown but well-formed the source application name, which is reconstructed from its +/// embedded dimensions. `None` only for names that carry no size at all. +pub fn resolve(name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + let spaced = name.replace('_', " "); + if let Some(paper) = catalog().iter().find(|paper| { + paper + .canonical + .replace('_', " ") + .eq_ignore_ascii_case(&spaced) + }) { + return Some(paper.clone()); + } + if let Some((_, canonical)) = ALIASES + .iter() + .find(|(alias, _)| alias.eq_ignore_ascii_case(name)) + { + return catalog() + .iter() + .find(|paper| paper.canonical == *canonical) + .cloned(); + } + PaperSize::parse_canonical(name) +} + +/// The catalogued sheet whose portrait size matches `(w, h)` in millimetres +/// (either order) within half a millimetre. Standard variants win over their +/// full-bleed / expand twins so a size read from a drawing without a usable +/// name lands on the plain sheet. +pub fn match_dimensions_mm(w: f64, h: f64) -> Option<&'static PaperSize> { + const TOLERANCE_MM: f64 = 0.5; + let (short, long) = if w <= h { (w, h) } else { (h, w) }; + catalog() + .iter() + .filter(|paper| paper.variant == PaperVariant::Standard) + .filter(|paper| { + let (pw, ph) = paper.portrait_mm(); + (pw - short).abs() <= TOLERANCE_MM && (ph - long).abs() <= TOLERANCE_MM + }) + // ANSI A and Letter share a size; prefer the named series. + .min_by_key(|paper| paper.series) +} + +/// Sheet for a name as read from a drawing: the catalogued or reconstructed +/// sheet when the name carries one, otherwise the catalogued sheet with the +/// given dimensions, otherwise a custom sheet built from the dimensions alone. +pub fn from_drawing(name: &str, width_mm: f64, height_mm: f64) -> PaperSize { + if let Some(paper) = resolve(name) { + return paper; + } + if let Some(paper) = match_dimensions_mm(width_mm, height_mm) { + return paper.clone(); + } + if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 { + return default_paper().clone(); + } + PaperSize::custom(width_mm, height_mm, PaperUnits::Millimeters) +} + +// ── Plot geometry helpers ───────────────────────────────────────────────── + +#[derive(Copy, Clone, PartialEq, Debug)] +pub enum PlotScale { + /// Scale so the window fills the sheet minus a 5% margin. + Fit, + /// Exact scale factor (mm per drawing unit). + Ratio(f64), +} + +/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y), +/// where the scaled window is centered on the sheet and (offset_x, offset_y) is +/// the sheet-mm position of the window's min corner. The caller turns that into +/// its own coordinate offset. +pub fn window_to_sheet( + window_wh: (f64, f64), + sheet_mm: (f64, f64), + scale: PlotScale, +) -> (f64, f64, f64) { + let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9)); + let scale = match scale { + PlotScale::Ratio(r) => r.max(1e-9), + PlotScale::Fit => { + const MARGIN: f64 = 1.05; + let sx = (sheet_mm.0 / MARGIN) / ww; + let sy = (sheet_mm.1 / MARGIN) / wh; + sx.min(sy) + } + }; + let scaled_w = ww * scale; + let scaled_h = wh * scale; + let offset_x = (sheet_mm.0 - scaled_w) / 2.0; + let offset_y = (sheet_mm.1 - scaled_h) / 2.0; + (scale, offset_x, offset_y) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn a4() -> PaperSize { + resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap() + } + + #[test] + fn catalogue_names_follow_source_app_spelling() { + let names: Vec<&str> = catalog().iter().map(|p| p.canonical.as_ref()).collect(); + for expected in [ + "ISO_A4_(210.00_x_297.00_MM)", + "ISO_full_bleed_A4_(210.00_x_297.00_MM)", + "ISO_expand_A0_(841.00_x_1189.00_MM)", + "ISO_B4_(250.00_x_353.00_MM)", + "ANSI_A_(8.50_x_11.00_Inches)", + "ANSI_full_bleed_B_(11.00_x_17.00_Inches)", + "ARCH_D_(24.00_x_36.00_Inches)", + "ARCH_expand_E1_(30.00_x_42.00_Inches)", + "JIS_B4_(257.00_x_364.00_MM)", + "Letter_(8.50_x_11.00_Inches)", + "Legal_(8.50_x_14.00_Inches)", + "Tabloid_(11.00_x_17.00_Inches)", + ] { + assert!(names.contains(&expected), "missing {expected}"); + } + // Every name is portrait and unique. + let mut seen = std::collections::HashSet::new(); + for paper in catalog() { + assert!( + paper.width <= paper.height, + "{} is not portrait", + paper.canonical + ); + assert!( + seen.insert(paper.canonical.clone()), + "duplicate {}", + paper.canonical + ); + } + } + + #[test] + fn every_catalogue_name_round_trips_through_the_parser() { + for paper in catalog() { + let parsed = PaperSize::parse_canonical(&paper.canonical) + .unwrap_or_else(|| panic!("cannot parse {}", paper.canonical)); + assert_eq!(parsed.canonical, paper.canonical); + assert_eq!(parsed.label, paper.label); + assert_eq!(parsed.series, paper.series); + assert_eq!(parsed.variant, paper.variant); + assert_eq!(parsed.units, paper.units); + assert!((parsed.width - paper.width).abs() < 1e-9); + assert!((parsed.height - paper.height).abs() < 1e-9); + } + } + + #[test] + fn unknown_canonical_names_are_reconstructed_and_preserved() { + // A custom size created in a plot dialog. + let custom = resolve("UserDefinedMetric_(420.00_x_297.00_MM)").unwrap(); + assert_eq!(custom.canonical, "UserDefinedMetric_(420.00_x_297.00_MM)"); + assert_eq!(custom.label, "UserDefinedMetric"); + assert_eq!(custom.series, PaperSeries::Other); + assert_eq!(custom.portrait_mm(), (297.0, 420.0)); + // A driver sheet the catalogue does not list, in inches. + let oversize = resolve("Oversize_A1_(625.00_x_880.00_MM)").unwrap(); + assert_eq!(oversize.portrait_mm(), (625.0, 880.0)); + // The dialog spelling with spaces resolves to the same canonical name. + let spaced = resolve("ISO full bleed A3 (297.00 x 420.00 MM)").unwrap(); + assert_eq!(spaced.canonical, "ISO_full_bleed_A3_(297.00_x_420.00_MM)"); + assert_eq!(spaced.variant, PaperVariant::FullBleed); + assert_eq!(spaced.series, PaperSeries::IsoA); + // A raster size or a bare driver name carries no usable dimensions. + assert!(resolve("Sun_Hi-Res_(1600.00_x_1280.00_Pixels)").is_none()); + assert!(resolve("A4 Plain").is_none()); + assert!(resolve("").is_none()); + } + + #[test] + fn aliases_and_case_insensitive_names_resolve() { + assert_eq!(resolve("A4").unwrap().canonical, a4().canonical); + assert_eq!( + resolve("a3").unwrap().canonical, + "ISO_A3_(297.00_x_420.00_MM)" + ); + assert_eq!( + resolve("Letter").unwrap().canonical, + "Letter_(8.50_x_11.00_Inches)" + ); + assert_eq!( + resolve("Ledger").unwrap().canonical, + "Tabloid_(11.00_x_17.00_Inches)" + ); + assert_eq!( + resolve("iso_a4_(210.00_x_297.00_mm)").unwrap().canonical, + a4().canonical + ); + } + + #[test] + fn dimensions_match_the_plain_sheet_in_either_orientation() { + assert_eq!( + match_dimensions_mm(297.0, 210.0).unwrap().canonical, + a4().canonical + ); + assert_eq!( + match_dimensions_mm(210.0, 297.0).unwrap().canonical, + a4().canonical + ); + assert_eq!( + match_dimensions_mm(210.3, 297.4).unwrap().canonical, + a4().canonical + ); + assert!(match_dimensions_mm(215.0, 297.0).is_none()); + // 8.5 × 11 in prefers ANSI A over the Letter alias sheet. + assert_eq!( + match_dimensions_mm(215.9, 279.4).unwrap().canonical, + "ANSI_A_(8.50_x_11.00_Inches)" + ); + assert_eq!( + match_dimensions_mm(609.6, 914.4).unwrap().canonical, + "ARCH_D_(24.00_x_36.00_Inches)" + ); + } + + #[test] + fn from_drawing_falls_back_from_name_to_size_to_custom() { + assert_eq!( + from_drawing("ISO_A3_(297.00_x_420.00_MM)", 0.0, 0.0).label, + "ISO A3" + ); + // A legacy drawing with a bare driver name and A4 dimensions. + assert_eq!( + from_drawing("Plain paper", 297.0, 210.0).canonical, + a4().canonical + ); + let custom = from_drawing("", 300.0, 500.0); + assert_eq!(custom.canonical, "UserDefinedMetric_(300.00_x_500.00_MM)"); + assert_eq!(custom.portrait_mm(), (300.0, 500.0)); + assert_eq!(from_drawing("", 0.0, f64::INFINITY), *default_paper()); + } + + #[test] + fn parser_rejects_non_finite_dimensions() { + assert!(PaperSize::parse_canonical("Custom_(inf_x_297.00_MM)").is_none()); + assert!(PaperSize::parse_canonical("Custom_(210.00_x_NaN_MM)").is_none()); + } + + #[test] + fn sheet_and_display_formatting() { + assert_eq!(a4().sheet_mm(Orientation::Portrait), (210.0, 297.0)); + assert_eq!(a4().sheet_mm(Orientation::Landscape), (297.0, 210.0)); + assert_eq!(a4().display(), "ISO A4 (210 × 297 mm)"); + let ansi_b = resolve("ANSI_B_(11.00_x_17.00_Inches)").unwrap(); + assert_eq!(ansi_b.display(), "ANSI B (11.00 × 17.00 in)"); + let (w, h) = ansi_b.portrait_mm(); + assert!((w - 279.4).abs() < 1e-9 && (h - 431.8).abs() < 1e-9); + assert_eq!( + PaperSize::custom(297.5, 210.0, PaperUnits::Millimeters).display(), + "UserDefinedMetric (210 × 297.5 mm)" + ); + assert_eq!(default_paper().canonical, a4().canonical); + } + + #[test] + fn user_defined_sheets_carry_their_name_and_margins() { + let roll = CustomPaper { + name: "Roll 24 (long)".into(), + width: 1500.0, + height: 609.6, + units: PaperUnits::Millimeters, + margins: Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, + }, + }; + assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)"); + assert_eq!(roll.paper().label, "Roll 24 long"); + assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)"); + // Round-trips through the parser like any the source application name. + assert_eq!( + resolve(&roll.canonical()).unwrap().portrait_mm(), + (609.6, 1500.0) + ); + let arch = CustomPaper { + name: String::new(), + width: 30.0, + height: 42.0, + units: PaperUnits::Inches, + margins: Margins::uniform(0.25), + }; + assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)"); + assert_eq!(arch.margins_mm(), Margins::uniform(6.35)); + } + + #[test] + fn fit_centers_and_scales_within_margin() { + // 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width: + // usable = 210/1.05 = 200; scale = 200/100 = 2.0. + let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit); + assert!((s - 2.0).abs() < 1e-9, "scale {s}"); + // Window is 100*2 = 200 wide/tall; centered on 210×297. + assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}"); + assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}"); + } + + #[test] + fn ratio_applies_exact_scale_and_centers() { + // Ratio(0.5): scale is exactly 0.5; the scaled window is centered and + // (ox, oy) is the sheet-mm position of its min corner. + let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5)); + assert!((s - 0.5).abs() < 1e-9); + // scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2). + assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}"); + assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}"); + } +} diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs deleted file mode 100644 index 916a961e3..000000000 --- a/src/io/paper_sizes.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Standard paper sizes and sheet orientation for window plotting. - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum PaperSize { - A4, - A3, - A2, - A1, - A0, - Letter, - Legal, - Tabloid, -} - -impl PaperSize { - pub const ALL: [PaperSize; 8] = [ - PaperSize::A4, - PaperSize::A3, - PaperSize::A2, - PaperSize::A1, - PaperSize::A0, - PaperSize::Letter, - PaperSize::Legal, - PaperSize::Tabloid, - ]; - - pub fn label(self) -> &'static str { - match self { - PaperSize::A4 => "A4", - PaperSize::A3 => "A3", - PaperSize::A2 => "A2", - PaperSize::A1 => "A1", - PaperSize::A0 => "A0", - PaperSize::Letter => "Letter", - PaperSize::Legal => "Legal", - PaperSize::Tabloid => "Tabloid", - } - } - - /// Portrait dimensions in mm (width, height); width < height. - pub fn dimensions_mm(self) -> (f64, f64) { - match self { - PaperSize::A4 => (210.0, 297.0), - PaperSize::A3 => (297.0, 420.0), - PaperSize::A2 => (420.0, 594.0), - PaperSize::A1 => (594.0, 841.0), - PaperSize::A0 => (841.0, 1189.0), - PaperSize::Letter => (215.9, 279.4), - PaperSize::Legal => (215.9, 355.6), - PaperSize::Tabloid => (279.4, 431.8), - } - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -pub enum Orientation { - Portrait, - Landscape, -} - -/// Sheet dimensions in mm for the given size and orientation. -pub fn sheet_mm(size: PaperSize, o: Orientation) -> (f64, f64) { - let (w, h) = size.dimensions_mm(); - match o { - Orientation::Portrait => (w, h), - Orientation::Landscape => (h, w), - } -} - -#[derive(Copy, Clone, PartialEq, Debug)] -pub enum PlotScale { - /// Scale so the window fills the sheet minus a 5% margin. - Fit, - /// Exact scale factor (mm per drawing unit). - Ratio(f64), -} - -/// Map a world-space window onto a sheet. Returns (scale, offset_x, offset_y), -/// where the scaled window is centered on the sheet and (offset_x, offset_y) is -/// the sheet-mm position of the window's min corner. The caller turns that into -/// its own coordinate offset. -pub fn window_to_sheet( - window_wh: (f64, f64), - sheet_mm: (f64, f64), - scale: PlotScale, -) -> (f64, f64, f64) { - let (ww, wh) = (window_wh.0.max(1e-9), window_wh.1.max(1e-9)); - let scale = match scale { - PlotScale::Ratio(r) => r.max(1e-9), - PlotScale::Fit => { - const MARGIN: f64 = 1.05; - let sx = (sheet_mm.0 / MARGIN) / ww; - let sy = (sheet_mm.1 / MARGIN) / wh; - sx.min(sy) - } - }; - let scaled_w = ww * scale; - let scaled_h = wh * scale; - let offset_x = (sheet_mm.0 - scaled_w) / 2.0; - let offset_y = (sheet_mm.1 - scaled_h) / 2.0; - (scale, offset_x, offset_y) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn paper_dimensions_and_orientation() { - assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0)); - assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0)); - assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4)); - assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6)); - assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8)); - assert_eq!(PaperSize::ALL.len(), 8); - assert_eq!(PaperSize::A3.label(), "A3"); - // Portrait keeps (w,h); landscape swaps. - assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0)); - assert_eq!(sheet_mm(PaperSize::A4, Orientation::Landscape), (297.0, 210.0)); - } - - #[test] - fn fit_centers_and_scales_within_margin() { - // 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width: - // usable = 210/1.05 = 200; scale = 200/100 = 2.0. - let (s, ox, oy) = window_to_sheet((100.0, 100.0), (210.0, 297.0), PlotScale::Fit); - assert!((s - 2.0).abs() < 1e-9, "scale {s}"); - // Window is 100*2 = 200 wide/tall; centered on 210×297. - assert!((ox - (210.0 - 200.0) / 2.0).abs() < 1e-9, "ox {ox}"); - assert!((oy - (297.0 - 200.0) / 2.0).abs() < 1e-9, "oy {oy}"); - } - - #[test] - fn ratio_applies_exact_scale_and_centers() { - // Ratio(0.5): scale is exactly 0.5; the scaled window is centered and - // (ox, oy) is the sheet-mm position of its min corner. - let (s, ox, oy) = window_to_sheet((100.0, 80.0), (210.0, 297.0), PlotScale::Ratio(0.5)); - assert!((s - 0.5).abs() < 1e-9); - // scaled window = 50×40; centered box origin = ((210-50)/2,(297-40)/2). - assert!((ox - (210.0 - 50.0) / 2.0).abs() < 1e-9, "ox {ox}"); - assert!((oy - (297.0 - 40.0) / 2.0).abs() < 1e-9, "oy {oy}"); - } -} diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs new file mode 100644 index 000000000..e29cc4583 --- /dev/null +++ b/src/io/plot_device.rs @@ -0,0 +1,599 @@ +//! Plot devices: what a page setup names as its plotter, and what that device +//! can print. +//! +//! the source application stores the device in `PlotSettings::printer_name` as the plotter +//! configuration's name — `DWG To PDF.pc3` for its PDF driver, the Windows +//! printer's name for a system printer, `None` when nothing is assigned. A +//! drawing saved here has to use the same spelling so the source application attaches the +//! layout to its own PDF driver instead of reporting a missing device; and a +//! device name that arrives from the source application (an HP plotter's `.pc3`, `DWF6 +//! ePlot.pc3`) must go back out untouched even though we cannot drive it. +//! +//! The device also decides the *printable area*: the sheet inset by the +//! driver's unprintable margins, which the source application writes into the layout and +//! draws as the dashed rectangle in paper space. The PDF driver's margins are +//! a fixed table; a CUPS printer reports its media and margins over IPP +//! (`media-col-database`), which is queried once per printer and cached. + +pub use crate::io::paper_catalog::Margins; +use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +/// the source application's plotter configuration name for PDF output. +pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3"; +/// the source application's spelling for "no plotter assigned" in the plot dialog… +pub const NO_DEVICE_NAME: &str = "None"; +/// …and the name it actually stores in the drawing for that choice. +const NO_DEVICE_STORED_NAME: &str = "none_device"; +/// The label older Open CAD Studio builds wrote into drawings for PDF output. +/// Recognised on read so those drawings keep plotting to PDF, and always +/// rewritten with [`PDF_DEVICE_NAME`] on save. +const LEGACY_PDF_LABEL: &str = "Save to PDF file…"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PlotDevice { + /// No plotter assigned (the source application `None`). We plot to the system default + /// printer in that case, which is what the plot dialog offers. + None, + /// PDF file output, the source application's `DWG To PDF.pc3`. + Pdf, + /// A system printer by its queue / spooler name. + Printer(String), +} + +impl PlotDevice { + /// The name the source application would store for this device. + pub fn canonical_name(&self) -> String { + match self { + PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(), + PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(), + PlotDevice::Printer(name) => name.clone(), + } + } + + /// Interpret a stored `printer_name`. Returns the device and whether the + /// stored spelling should be preserved on save: an the source application plotter + /// configuration we cannot drive (`DWF6 ePlot.pc3`, an HP `.pc3`) is + /// treated as PDF output but keeps its name, so the drawing still attaches + /// to that plotter in the source application; our own legacy label and an empty name are + /// not worth preserving. + pub fn from_stored_name(name: &str) -> (Self, bool) { + let name = name.trim(); + if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) { + return (PlotDevice::None, false); + } + if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) { + // the source application's own spelling: keep it rather than rewriting it. + return (PlotDevice::None, true); + } + if name == LEGACY_PDF_LABEL { + return (PlotDevice::Pdf, false); + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".pc3") { + // Any the source application plotter configuration: PDF ones are ours to drive, + // the rest plot to PDF here but keep their name for the source application. + return (PlotDevice::Pdf, true); + } + (PlotDevice::Printer(name.to_string()), true) + } + + /// Unprintable margins the device imposes on `paper`, in millimetres. + /// A sheet the user defined carries its own margins for every device, + /// the way a custom size in a plotter configuration does; a printer that + /// reports the same size still wins, since it knows its hardware. + pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins { + let user_defined = custom + .iter() + .find(|c| c.canonical() == paper.canonical) + .map(CustomPaper::margins_mm); + match self { + PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)), + PlotDevice::Printer(name) => cached_printer_capabilities(name) + .and_then(|caps| caps.margins_for(paper)) + .or(user_defined) + .unwrap_or(Margins::ZERO), + // No device: the source application keeps the margins of the last device or its + // defaults; without one we show the full sheet as printable. + PlotDevice::None => user_defined.unwrap_or(Margins::ZERO), + } + } +} + +// ── PDF driver ──────────────────────────────────────────────────────────── + +/// Unprintable margins of the source application's `DWG To PDF.pc3` sheets, as its Plotter +/// Configuration Editor lists them (Modify Standard Paper Sizes). They depend +/// on the sheet family and variant, not on the individual size: every ISO +/// sheet shares one set in millimetres, every ANSI / ARCH sheet one set in +/// inches. Order inside each set is left, bottom, right, top. +const PDF_MM_STANDARD: Margins = Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, +}; +const PDF_MM_EXPAND: Margins = Margins { + left: 5.0, + bottom: 10.0, + right: 6.0, + top: 11.0, +}; +const PDF_MM_FULL_BLEED: Margins = Margins { + left: 0.0, + bottom: 1.0, + right: 0.0, + top: 1.0, +}; +const PDF_IN_STANDARD: Margins = Margins { + left: 0.23, + bottom: 0.70, + right: 0.23, + top: 0.70, +}; +const PDF_IN_EXPAND: Margins = Margins { + left: 0.23, + bottom: 0.42, + right: 0.23, + top: 0.42, +}; +const PDF_IN_FULL_BLEED: Margins = Margins::uniform(0.03); + +fn pdf_margins_mm(paper: &PaperSize) -> Margins { + let set = match (paper.units, paper.variant) { + (PaperUnits::Millimeters, PaperVariant::Standard) => PDF_MM_STANDARD, + (PaperUnits::Millimeters, PaperVariant::Expand) => PDF_MM_EXPAND, + (PaperUnits::Millimeters, PaperVariant::FullBleed) => PDF_MM_FULL_BLEED, + (PaperUnits::Inches, PaperVariant::Standard) => PDF_IN_STANDARD, + (PaperUnits::Inches, PaperVariant::Expand) => PDF_IN_EXPAND, + (PaperUnits::Inches, PaperVariant::FullBleed) => PDF_IN_FULL_BLEED, + }; + set.to_mm(paper.units) +} + +// ── System printers ─────────────────────────────────────────────────────── + +/// One sheet a printer reports it can load, with its unprintable margins. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterMedia { + /// The sheet as our catalogue knows it (matched by size), or a custom + /// sheet built from the reported dimensions. + pub paper: PaperSize, + pub margins: Margins, + /// `true` when the printer also reports a borderless variant of this size. + pub borderless: bool, +} + +/// What a printer reported about its media, cached for the session. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PrinterCapabilities { + /// Sheets in the printer's own order, one entry per distinct size. + pub media: Vec, + /// The printer's default sheet, when reported. + pub default_paper: Option, +} + +impl PrinterCapabilities { + /// Margins the printer reports for `paper`, matched by size. `None` when + /// the printer never mentioned a sheet of that size. + pub fn margins_for(&self, paper: &PaperSize) -> Option { + let (w, h) = paper.portrait_mm(); + self.media + .iter() + .find(|media| { + let (mw, mh) = media.paper.portrait_mm(); + (mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5 + }) + .map(|media| media.margins) + } +} + +fn capability_cache() -> &'static Mutex>> { + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// The cached capabilities of `printer`, if they were queried already. +pub fn cached_printer_capabilities(printer: &str) -> Option> { + capability_cache().lock().ok()?.get(printer).cloned() +} + +/// Query `printer` for its media (blocking, one process spawn) and cache the +/// answer. Returns `None` when the platform cannot answer — the plot dialog +/// then falls back to the paper catalogue with unverified margins. +pub fn printer_capabilities(printer: &str) -> Option> { + if let Some(cached) = cached_printer_capabilities(printer) { + return Some(cached); + } + let caps = Arc::new(query_printer_capabilities(printer)?); + if let Ok(mut cache) = capability_cache().lock() { + cache.insert(printer.to_string(), Arc::clone(&caps)); + } + Some(caps) +} + +/// Name of the system default printer, when the platform reports one. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +pub fn default_printer_name() -> Option { + // `lpstat -d` → "system default destination: NAME" (the prefix is + // localized, the name follows the last colon). + let output = std::process::Command::new("lpstat") + .arg("-d") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let line = text.lines().find(|line| line.contains(':'))?; + let name = line.rsplit(':').next()?.trim(); + (!name.is_empty()).then(|| name.to_string()) +} + +/// Windows default printer discovery lands with native GDI printing. +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +pub fn default_printer_name() -> Option { + None +} + +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +fn query_printer_capabilities(printer: &str) -> Option { + // cupsd answers for the queue itself, so this is quick even when the + // printer is off; `ipptool` is part of every CUPS install that has + // `lpstat`. The standard test file asks for every attribute; the two we + // read are `media-col-database` and `media-default`. + let uri = format!("ipp://localhost/printers/{}", printer.replace(' ', "%20")); + let output = std::process::Command::new("ipptool") + .args(["-tv", &uri, "get-printer-attributes.test"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + parse_ipp_media(&String::from_utf8_lossy(&output.stdout)) +} + +/// Windows media enumeration (`DeviceCapabilities`) lands with native GDI +/// printing; until then the catalogue serves every printer. +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +fn query_printer_capabilities(_printer: &str) -> Option { + None +} + +/// Read the media a printer reports from `ipptool -tv` output. +/// +/// The relevant lines look like +/// `media-col-database (1setOf collection) = {media-size={x-dimension=21000 +/// y-dimension=29700} media-bottom-margin=300 media-left-margin=300 …},{…}` +/// with sizes and margins in hundredths of a millimetre, and +/// `media-default (keyword) = iso_a4_210x297mm`. Borderless variants repeat a +/// size with zero margins; they collapse onto the bordered entry. +fn parse_ipp_media(text: &str) -> Option { + let database = attribute_value(text, "media-col-database")?; + let mut media: Vec = Vec::new(); + for entry in database.split("},{") { + let Some((w, h)) = collection_size_mm(entry) else { + continue; + }; + let margins = Margins { + left: hundredths_mm(entry, "media-left-margin"), + bottom: hundredths_mm(entry, "media-bottom-margin"), + right: hundredths_mm(entry, "media-right-margin"), + top: hundredths_mm(entry, "media-top-margin"), + }; + let borderless = margins == Margins::ZERO; + if let Some(existing) = media.iter_mut().find(|m| { + let (mw, mh) = m.paper.portrait_mm(); + let (pw, ph) = portrait(w, h); + (mw - pw).abs() <= 0.5 && (mh - ph).abs() <= 0.5 + }) { + // Keep the bordered margins as the sheet's printable area and + // remember that edge-to-edge is possible too. + if borderless { + existing.borderless = true; + } else if existing.margins == Margins::ZERO { + existing.margins = margins; + existing.borderless = true; + } + continue; + } + let (pw, ph) = portrait(w, h); + let paper = paper_catalog::match_dimensions_mm(pw, ph) + .cloned() + .unwrap_or_else(|| PaperSize::custom(pw, ph, PaperUnits::Millimeters)); + media.push(PrinterMedia { + paper, + margins, + borderless, + }); + } + if media.is_empty() { + return None; + } + let default_paper = attribute_value(text, "media-default") + .and_then(|name| pwg_size_mm(name.trim())) + .and_then(|(w, h)| { + let (w, h) = portrait(w, h); + media + .iter() + .find(|m| { + let (mw, mh) = m.paper.portrait_mm(); + (mw - w).abs() <= 0.5 && (mh - h).abs() <= 0.5 + }) + .map(|m| m.paper.clone()) + }); + Some(PrinterCapabilities { + media, + default_paper, + }) +} + +/// The text after `name (…) = ` on the line that declares `name`. +fn attribute_value<'a>(text: &'a str, name: &str) -> Option<&'a str> { + text.lines().find_map(|line| { + let line = line.trim_start(); + let rest = line.strip_prefix(name)?; + // The attribute name is followed by its syntax in parentheses. + if !rest.starts_with(' ') && !rest.starts_with('(') { + return None; + } + let (_, value) = rest.split_once('=')?; + Some(value.trim()) + }) +} + +fn collection_size_mm(entry: &str) -> Option<(f64, f64)> { + let x = hundredths(entry, "x-dimension")?; + let y = hundredths(entry, "y-dimension")?; + (x > 0.0 && y > 0.0).then_some((x, y)) +} + +fn hundredths(entry: &str, key: &str) -> Option { + let start = entry.find(key)? + key.len(); + let rest = entry[start..].trim_start_matches('='); + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + digits.parse::().ok().map(|v| v / 100.0) +} + +fn hundredths_mm(entry: &str, key: &str) -> f64 { + hundredths(entry, key).unwrap_or(0.0) +} + +fn portrait(w: f64, h: f64) -> (f64, f64) { + if w <= h { + (w, h) + } else { + (h, w) + } +} + +/// Dimensions from a PWG self-describing media name such as +/// `iso_a4_210x297mm` or `na_letter_8.5x11in`. +fn pwg_size_mm(name: &str) -> Option<(f64, f64)> { + let dims = name.rsplit('_').next()?; + let (dims, units) = dims + .strip_suffix("mm") + .map(|d| (d, PaperUnits::Millimeters)) + .or_else(|| dims.strip_suffix("in").map(|d| (d, PaperUnits::Inches)))?; + let (w, h) = dims.split_once('x')?; + let w: f64 = w.parse().ok()?; + let h: f64 = h.parse().ok()?; + Some((units.to_mm(w), units.to_mm(h))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const IPP_SAMPLE: &str = " media-default (keyword) = iso_a4_210x297mm\n\ + media-supported (1setOf keyword) = iso_a4_210x297mm,om_a-4.borderless_210x297mm,na_letter_8.5x11in,custom_215x345mm_215x345mm\n\ + media-col-database (1setOf collection) = {media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300},{media-size={x-dimension=21000 y-dimension=29700} media-bottom-margin=0 media-left-margin=0 media-right-margin=0 media-top-margin=0},{media-size={x-dimension=21590 y-dimension=27940} media-bottom-margin=635 media-left-margin=635 media-right-margin=635 media-top-margin=635},{media-size={x-dimension=21500 y-dimension=34500} media-bottom-margin=300 media-left-margin=300 media-right-margin=300 media-top-margin=300}\n"; + + #[test] + fn canonical_names_follow_source_app() { + assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3"); + // What the source application writes for "None" in its plot dialog. + assert_eq!(PlotDevice::None.canonical_name(), "none_device"); + assert_eq!( + PlotDevice::Printer("L3270".into()).canonical_name(), + "L3270" + ); + } + + #[test] + fn stored_names_resolve_and_say_whether_to_keep_them() { + assert_eq!(PlotDevice::from_stored_name(""), (PlotDevice::None, false)); + assert_eq!( + PlotDevice::from_stored_name("None"), + (PlotDevice::None, false) + ); + assert_eq!( + PlotDevice::from_stored_name("none_device"), + (PlotDevice::None, true) + ); + assert_eq!( + PlotDevice::from_stored_name("NONE_DEVICE"), + (PlotDevice::None, true) + ); + assert_eq!( + PlotDevice::from_stored_name("Save to PDF file…"), + (PlotDevice::Pdf, false) + ); + assert_eq!( + PlotDevice::from_stored_name("DWG To PDF.pc3"), + (PlotDevice::Pdf, true) + ); + assert_eq!( + PlotDevice::from_stored_name("the source application PDF (High Quality Print).pc3"), + (PlotDevice::Pdf, true) + ); + // A plotter we cannot drive plots to PDF here but keeps its name. + assert_eq!( + PlotDevice::from_stored_name("DWF6 ePlot.pc3"), + (PlotDevice::Pdf, true) + ); + assert_eq!( + PlotDevice::from_stored_name("HP LaserJet 4"), + (PlotDevice::Printer("HP LaserJet 4".into()), true) + ); + } + + #[test] + fn pdf_margins_follow_source_app_per_family_and_variant() { + let m = + |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]); + // ISO sheets share one millimetre set regardless of size. + let iso_standard = Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, + }; + assert_eq!(m("ISO_A4_(210.00_x_297.00_MM)"), iso_standard); + assert_eq!(m("ISO_A0_(841.00_x_1189.00_MM)"), iso_standard); + assert_eq!( + m("ISO_expand_A4_(210.00_x_297.00_MM)"), + Margins { + left: 5.0, + bottom: 10.0, + right: 6.0, + top: 11.0 + } + ); + assert_eq!( + m("ISO_full_bleed_A4_(210.00_x_297.00_MM)"), + Margins { + left: 0.0, + bottom: 1.0, + right: 0.0, + top: 1.0 + } + ); + // Inch sheets are listed in inches and stored in millimetres. + let close = |a: Margins, b: Margins| { + (a.left - b.left).abs() < 1e-9 + && (a.bottom - b.bottom).abs() < 1e-9 + && (a.right - b.right).abs() < 1e-9 + && (a.top - b.top).abs() < 1e-9 + }; + let inch = |l: f64, b: f64, r: f64, t: f64| Margins { + left: l * 25.4, + bottom: b * 25.4, + right: r * 25.4, + top: t * 25.4, + }; + assert!(close( + m("ANSI_A_(8.50_x_11.00_Inches)"), + inch(0.23, 0.70, 0.23, 0.70) + )); + assert!(close( + m("ARCH_D_(24.00_x_36.00_Inches)"), + inch(0.23, 0.70, 0.23, 0.70) + )); + assert!(close( + m("ARCH_expand_D_(24.00_x_36.00_Inches)"), + inch(0.23, 0.42, 0.23, 0.42) + )); + assert!(close( + m("ANSI_full_bleed_B_(11.00_x_17.00_Inches)"), + inch(0.03, 0.03, 0.03, 0.03) + )); + assert_eq!( + PlotDevice::None.margins_mm( + &paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(), + &[] + ), + Margins::ZERO + ); + } + + #[test] + fn user_defined_sheets_bring_their_own_margins() { + let roll = CustomPaper { + name: "Roll".into(), + width: 900.0, + height: 1200.0, + units: PaperUnits::Millimeters, + margins: Margins::uniform(3.0), + }; + let paper = roll.paper(); + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + assert_eq!( + PlotDevice::None.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + // An unknown printer falls back to the user's margins too. + assert_eq!( + PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]), + Margins::uniform(3.0) + ); + // Without a definition a custom-looking sheet gets the family set. + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[]), + Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0 + } + ); + } + + #[test] + fn ipp_media_parses_sizes_margins_borderless_and_default() { + let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed"); + assert_eq!( + caps.media.len(), + 3, + "borderless A4 folds into the bordered entry" + ); + let a4 = &caps.media[0]; + assert_eq!(a4.paper.canonical, "ISO_A4_(210.00_x_297.00_MM)"); + assert_eq!(a4.margins, Margins::uniform(3.0)); + assert!(a4.borderless); + let letter = &caps.media[1]; + assert_eq!(letter.paper.canonical, "ANSI_A_(8.50_x_11.00_Inches)"); + assert_eq!(letter.margins, Margins::uniform(6.35)); + assert!(!letter.borderless); + let custom = &caps.media[2]; + assert_eq!( + custom.paper.canonical, + "UserDefinedMetric_(215.00_x_345.00_MM)" + ); + assert_eq!( + caps.default_paper.as_ref().map(|p| p.canonical.as_ref()), + Some("ISO_A4_(210.00_x_297.00_MM)") + ); + // Margin lookup by sheet size, either orientation, tolerant of the + // catalogue's inch → mm rounding. + let ansi_a = paper_catalog::resolve("Letter_(8.50_x_11.00_Inches)").unwrap(); + assert_eq!(caps.margins_for(&ansi_a), Some(Margins::uniform(6.35))); + let a3 = paper_catalog::resolve("ISO_A3_(297.00_x_420.00_MM)").unwrap(); + assert_eq!(caps.margins_for(&a3), None); + } + + #[test] + fn ipp_output_without_media_is_rejected() { + assert!(parse_ipp_media("printer-state (enum) = 3\n").is_none()); + assert!(parse_ipp_media( + "media-col-database (1setOf collection) = {media-size={x-dimension=0 y-dimension=0}}\n" + ) + .is_none()); + } + + #[test] + fn pwg_names_carry_their_size() { + assert_eq!(pwg_size_mm("iso_a4_210x297mm"), Some((210.0, 297.0))); + let (w, h) = pwg_size_mm("na_letter_8.5x11in").unwrap(); + assert!((w - 215.9).abs() < 1e-9 && (h - 279.4).abs() < 1e-9); + assert_eq!( + pwg_size_mm("custom_215x345mm_215x345mm"), + Some((215.0, 345.0)) + ); + assert_eq!(pwg_size_mm("iso_a4"), None); + } +} diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 35129dd61..910f58dde 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -47,6 +47,12 @@ pub struct PrintOptions { /// setting wins, so the field is legitimately unread there. #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, + /// Driver options chosen in the printer-properties editor, as CUPS + /// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read + /// on the CUPS path (`lp -o`); Windows keeps such choices in the + /// printer's own preferences instead. + #[cfg_attr(target_os = "windows", allow(dead_code))] + pub driver_options: Vec<(String, String)>, } #[cfg(target_arch = "wasm32")] @@ -238,13 +244,16 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec ( "rundll32.exe", vec![ "printui.dll,PrintUIEntry".to_string(), - "/p".to_string(), + "/e".to_string(), "/n".to_string(), name.to_string(), ], @@ -263,6 +272,7 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec) -> (&'static str, Vec) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + return open_desktop_printer_settings(printer); + } + #[allow(unreachable_code)] let (program, args) = printer_properties_command(printer); std::process::Command::new(program) .args(args) @@ -406,6 +421,10 @@ fn dispatch_to_printer_opts( }; cmd.arg("-o").arg(format!("print-quality={pq}")); } + // The driver's own options, exactly as the PPD names them. + for (key, value) in &opts.driver_options { + cmd.arg("-o").arg(format!("{key}={value}")); + } let lp_result = cmd .arg("--") .arg(path_str.as_ref()) @@ -468,7 +487,7 @@ mod printer_properties_tests { "rundll32.exe", vec![ "printui.dll,PrintUIEntry".to_string(), - "/p".to_string(), + "/e".to_string(), "/n".to_string(), "Office LaserJet".to_string(), ], @@ -523,3 +542,288 @@ mod printer_properties_tests { ); } } + +// ── Driver options (CUPS PPD) ───────────────────────────────────────────── + +/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it: +/// the PPD keyword, its description, the choices, and the current default. +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterOption { + /// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`. + pub key: String, + /// The driver's description, e.g. `Media Type`. + pub label: String, + /// Keyword of the currently selected choice. + pub default: String, + pub choices: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PrinterChoice { + /// PPD choice keyword, e.g. `PhotographicHighGloss`. + pub keyword: String, + /// Readable form of the keyword, e.g. `Photographic High Gloss`. + pub label: String, +} + +impl PrinterOption { + pub fn label_for(&self, keyword: &str) -> String { + self.choices + .iter() + .find(|choice| choice.keyword == keyword) + .map(|choice| choice.label.clone()) + .unwrap_or_else(|| humanize_keyword(keyword)) + } +} + +/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn). +/// The sheet (`PageSize`) is left out: the plot dialog chooses it. +#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] +pub fn printer_options(printer: &str) -> Result, String> { + let output = std::process::Command::new("lpoptions") + .args(["-p", printer, "-l"]) + .output() + .map_err(|error| format!("lpoptions: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout))) +} + +/// Windows drivers keep their options behind `DocumentProperties`; there is +/// no list to show in-line. +#[cfg(any(target_arch = "wasm32", target_os = "windows"))] +pub fn printer_options(_printer: &str) -> Result, String> { + Ok(Vec::new()) +} + +/// Parse `lpoptions -l` output: one option per line as +/// `Key/Description: choice *default choice …`, the asterisk marking the +/// current default. Numeric-range options print the same way and are kept. +pub fn parse_lpoptions(text: &str) -> Vec { + let mut options = Vec::new(); + for line in text.lines() { + let Some((head, tail)) = line.split_once(':') else { + continue; + }; + let (key, label) = head + .split_once('/') + .map(|(key, label)| (key.trim(), label.trim())) + .unwrap_or((head.trim(), head.trim())); + if key.is_empty() || key.eq_ignore_ascii_case("PageSize") { + continue; + } + let mut default = String::new(); + let choices: Vec = tail + .split_whitespace() + .map(|token| { + let (keyword, is_default) = match token.strip_prefix('*') { + Some(keyword) => (keyword, true), + None => (token, false), + }; + if is_default { + default = keyword.to_string(); + } + PrinterChoice { + keyword: keyword.to_string(), + label: humanize_keyword(keyword), + } + }) + .collect(); + if choices.is_empty() { + continue; + } + if default.is_empty() { + default = choices[0].keyword.clone(); + } + options.push(PrinterOption { + key: key.to_string(), + label: if label.is_empty() { key.to_string() } else { label.to_string() }, + default, + choices, + }); + } + options +} + +/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` → +/// `Duplex No Tumble`; keywords that are already words or numbers pass +/// through. Consecutive capitals (`RGB`, `A4`) stay together. +pub fn humanize_keyword(keyword: &str) -> String { + let mut out = String::with_capacity(keyword.len() + 4); + let chars: Vec = keyword.chars().collect(); + for (index, &ch) in chars.iter().enumerate() { + if index > 0 && ch.is_ascii_uppercase() { + let previous = chars[index - 1]; + let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase()); + if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) { + out.push(' '); + } + } + out.push(ch); + } + out +} + +/// The desktop environment's printer settings panel — where the driver +/// options cannot be listed (no CUPS) the user still gets somewhere useful. +/// Tries the panel of the running desktop first, then the +/// distribution-neutral tools, and only then the CUPS web interface. +#[cfg(target_os = "linux")] +pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> { + let desktop = std::env::var("XDG_CURRENT_DESKTOP") + .unwrap_or_default() + .to_ascii_lowercase(); + let mut candidates: Vec<(&str, Vec)> = Vec::new(); + if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") { + candidates.push(("gnome-control-center", vec!["printers".into()])); + } + if desktop.contains("kde") || desktop.contains("plasma") { + candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()])); + candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()])); + } + candidates.push(("system-config-printer", Vec::new())); + candidates.push(("gnome-control-center", vec!["printers".into()])); + for (program, args) in candidates { + if std::process::Command::new(program) + .args(&args) + .spawn() + .is_ok() + { + return Ok(()); + } + } + let target = printer + .map(|name| format!("http://localhost:631/printers/{name}")) + .unwrap_or_else(|| "http://localhost:631/printers".to_string()); + std::process::Command::new("xdg-open") + .arg(target) + .spawn() + .map(|_| ()) + .map_err(|error| format!("Could not open printer settings: {error}")) +} + +/// Show the driver's own document-properties sheet for `printer`, owned by +/// the window `owner` (an `HWND`, 0 for none), and on OK store the result as +/// the user's printing preferences for that printer — which is what the +/// print job then honours. Returns `Ok(false)` when the user cancelled. +#[cfg(target_os = "windows")] +pub fn edit_printer_preferences(printer: &str, owner: isize) -> Result { + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Graphics::Gdi::DEVMODEW; + use windows_sys::Win32::Graphics::Printing::{ + ClosePrinter, DocumentPropertiesW, OpenPrinterW, SetPrinterW, PRINTER_INFO_9W, + }; + // winspool.h: DM_OUT_BUFFER = DM_COPY, DM_IN_PROMPT = DM_PROMPT, + // DM_IN_BUFFER = DM_MODIFY. + const DM_OUT_BUFFER: u32 = 2; + const DM_IN_PROMPT: u32 = 4; + const DM_IN_BUFFER: u32 = 8; + const IDOK: i32 = 1; + + let name: Vec = printer.encode_utf16().chain(std::iter::once(0)).collect(); + let mut handle: HANDLE = std::ptr::null_mut(); + // SAFETY: `name` is NUL-terminated and outlives the call; a null default + // asks for PRINTER_ACCESS_USE, enough for per-user preferences. + if unsafe { OpenPrinterW(name.as_ptr(), &mut handle, std::ptr::null()) } == 0 { + return Err(format!("Could not open printer \"{printer}\"")); + } + let owner = owner as windows_sys::Win32::Foundation::HWND; + let outcome = (|| { + // SAFETY: with null buffers the call only reports the DEVMODE size. + let size = unsafe { + DocumentPropertiesW(owner, handle, name.as_ptr(), std::ptr::null_mut(), std::ptr::null(), 0) + }; + if size <= 0 { + return Err(format!("Printer \"{printer}\" reports no document properties")); + } + // DEVMODEW is u16-aligned; a u16 buffer keeps the cast sound. + let words = (size as usize).div_ceil(2); + let mut current = vec![0u16; words]; + let mut edited = vec![0u16; words]; + // SAFETY: both buffers hold `size` bytes as the driver requested. + let fetched = unsafe { + DocumentPropertiesW( + owner, + handle, + name.as_ptr(), + current.as_mut_ptr().cast::(), + std::ptr::null(), + DM_OUT_BUFFER, + ) + }; + if fetched < 0 { + return Err(format!("Could not read the preferences of \"{printer}\"")); + } + edited.copy_from_slice(¤t); + // SAFETY: as above; DM_IN_PROMPT runs the driver's modal sheet. + let response = unsafe { + DocumentPropertiesW( + owner, + handle, + name.as_ptr(), + edited.as_mut_ptr().cast::(), + current.as_ptr().cast::(), + DM_IN_BUFFER | DM_IN_PROMPT | DM_OUT_BUFFER, + ) + }; + if response != IDOK { + return Ok(false); + } + let info = PRINTER_INFO_9W { + pDevMode: edited.as_mut_ptr().cast::(), + }; + // SAFETY: level 9 takes a PRINTER_INFO_9W; the DEVMODE outlives the call. + if unsafe { SetPrinterW(handle, 9, (&info as *const PRINTER_INFO_9W).cast::(), 0) } == 0 { + return Err(format!("Could not save the preferences of \"{printer}\"")); + } + Ok(true) + })(); + // SAFETY: `handle` came from OpenPrinterW above. + unsafe { ClosePrinter(handle) }; + outcome +} + +#[cfg(test)] +mod driver_option_tests { + use super::*; + + const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\ +MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\ +ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\ +Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\ +cupsPrintQuality/Print Quality: *Normal High\n\ +print-scaling/Print Scaling: auto auto-fit fill *fit none\n\ +Broken line without colon\n"; + + #[test] + fn lpoptions_output_becomes_options_with_defaults() { + let options = parse_lpoptions(LPOPTIONS); + let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect(); + // PageSize is the plot dialog's business; everything else is listed. + assert_eq!( + keys, + ["MediaType", "ColorModel", "Duplex", "cupsPrintQuality", "print-scaling"] + ); + let media = &options[0]; + assert_eq!(media.label, "Media Type"); + assert_eq!(media.default, "Stationery"); + assert_eq!(media.choices[1].keyword, "PhotographicHighGloss"); + assert_eq!(media.choices[1].label, "Photographic High Gloss"); + assert_eq!(media.label_for("Envelope"), "Envelope"); + let scaling = &options[4]; + assert_eq!(scaling.default, "fit"); + assert_eq!(scaling.choices.len(), 5); + } + + #[test] + fn keywords_read_as_words() { + assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble"); + assert_eq!(humanize_keyword("RGB"), "RGB"); + assert_eq!(humanize_keyword("AutoGray"), "Auto Gray"); + assert_eq!(humanize_keyword("ProcessGray"), "Process Gray"); + assert_eq!(humanize_keyword("auto-fit"), "auto-fit"); + assert_eq!(humanize_keyword("PhotographicSemiGloss"), "Photographic Semi Gloss"); + assert_eq!(humanize_keyword("A4"), "A4"); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index ee157405b..ca31119d5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1456,6 +1456,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Layout Print Configuration" => Some(("properties", "layout-print-configuration")), "Layout name cannot be empty." => Some(("common", "layout-name-cannot-be-empty")), "Layout plots the current sheet using the selected paper size." => Some(("plot", "layout-plots-the-current-sheet-using-the-selec")), + "Sheet sizes and printable areas reported by the printer." => Some(("plot", "sheet-sizes-and-printable-areas-reported-by-th")), + "Custom paper size" => Some(("plot", "custom-paper-size")), + "Printable margins" => Some(("plot", "printable-margins")), + "Enter a positive width and height." => Some(("plot", "enter-a-positive-width-and-height")), + "Paper size" => Some(("plot", "paper-size")), + "{count} driver option(s) set for this printer." => Some(("plot", "count-driver-option-s-set-for-this-printer")), + "Printer properties — {printer}" => Some(("plot", "printer-properties-printer")), + "This printer reports no driver options." => Some(("plot", "this-printer-reports-no-driver-options")), + "Could not read the printer's options: {error}" => Some(("plot", "could-not-read-the-printer-s-options-error")), + "Reading the printer's options…" => Some(("plot", "reading-the-printer-s-options")), + "Printing preferences saved for {printer}." => Some(("plot", "printing-preferences-saved-for-printer")), + "Printing preferences unchanged." => Some(("plot", "printing-preferences-unchanged")), + "Printer properties" => Some(("plot", "printer-properties")), "Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")), "Layouts" => Some(("layout", "layouts")), "Leader" => Some(("common", "leader")), diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs new file mode 100644 index 000000000..6f79d4fec --- /dev/null +++ b/src/ui/style/form.rs @@ -0,0 +1,213 @@ +//! Form building blocks shared by the in-canvas dialogs. +//! +//! The Plot dialog grew its own `label : control` rows, field and button +//! styles, and section dividers; every dialog that followed copied them. They +//! live here once so a new dialog reads like the existing ones and a theme +//! tweak lands everywhere. + +use crate::app::Message; +use crate::ui::style::common::muted_style; +use iced::widget::{button, container, pick_list, row, text, text_input, Space}; +use iced::{Background, Border, Element, Length, Theme}; +use std::borrow::Cow; + +/// Width of the label column in a `label : control` row. +pub const LABEL_WIDTH: f32 = 92.0; + +/// Dialog button: accent (primary action) or neutral. +pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { + move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (accent, status) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong, + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + } + } +} + +/// Text field: base background, neutral border that turns primary on focus. +pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; + text_input::Style { + background: Background::Color(palette.background.base.color), + border: Border { + color: border, + width: 1.0, + radius: 3.0.into(), + }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +/// One-pixel horizontal rule. +pub fn hdivider<'a>(width: Length) -> Element<'a, Message> { + container(Space::new().width(width).height(1)) + .width(width) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// One-pixel vertical rule. +pub fn vseparator<'a>(height: Length) -> Element<'a, Message> { + container(Space::new().width(1).height(height)) + .width(1) + .height(height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// Muted section heading. +pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label).size(11).style(muted_style).into() +} + +fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label) + .size(11) + .style(muted_style) + .width(LABEL_WIDTH) + .into() +} + +/// A `label : dropdown` row. +pub fn labeled_pick_list<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + let list = pick_list(selected, options, |value| value.to_string()) + .on_select(on_select) + .text_size(12) + .padding([3, 6]) + .width(width); + row![row_label(label), list] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : dropdown` row that renders read-only when `enabled` is false. +pub fn labeled_pick_list_enabled<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, + enabled: bool, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + if enabled { + return labeled_pick_list(label, options, selected, on_select, width); + } + row![ + row_label(label), + crate::ui::read_only::field( + selected + .map(|choice| choice.to_string()) + .unwrap_or_default() + .as_str(), + 12.0, + width, + ), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row. +pub fn labeled_field<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, +) -> Element<'a, Message> { + row![ + row_label(label), + text_input("", value) + .on_input(on_input) + .style(field_style) + .size(12) + .width(width), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row that renders read-only when `enabled` is false. +pub fn labeled_field_enabled<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return labeled_field(label, value, on_input, width); + } + row![ + row_label(label), + crate::ui::read_only::field(value, 12.0, Length::Fixed(width)), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` pair with its own label width, for rows that put +/// several small values side by side. +pub fn labeled_field_compact<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + label_width: f32, + field_width: f32, +) -> Element<'a, Message> { + row![ + text(label).size(11).style(muted_style).width(label_width), + text_input("", value) + .on_input(on_input) + .style(field_style) + .size(12) + .padding([3, 6]) + .width(field_width), + ] + .spacing(6) + .align_y(iced::Center) + .into() +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index f8c3fa3c7..2a86accac 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -10,6 +10,7 @@ pub mod scale_manager; pub mod anno_object_scale; pub mod style_manager; pub mod common; +pub mod form; #[cfg(test)] mod common_tests { diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f4d744990..594263e54 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,7 +5,10 @@ //! PDF. Styled to match the other OCS dialogs (dark pills + fields). use crate::app::Message; -use crate::io::paper_sizes::PaperSize; +use crate::io::paper_catalog::{self, CustomPaper, Margins, PaperSize, PaperUnits}; +use crate::io::plot_device::PrinterCapabilities; +use crate::ui::style::common::muted_style; +use crate::ui::style::form; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -28,25 +31,39 @@ pub const STYLE_NONE: &str = ""; /// A plot dropdown value keeps its persisted/raw value separate from the /// localized label shown by Iced. Printer names, scale names, paper sizes, and /// style-table file names remain verbatim; built-in choices use the catalog. +/// A paper choice keeps the canonical media name as its value and shows the +/// sheet's human name with its dimensions. #[derive(Clone, Debug, Eq, PartialEq)] struct PlotChoice { raw: String, localized: bool, + /// Display text when it differs from `raw` (paper sizes). + display: Option, } impl PlotChoice { fn raw(value: impl Into) -> Self { - Self { raw: value.into(), localized: false } + Self { raw: value.into(), localized: false, display: None } } fn localized(value: impl Into) -> Self { - Self { raw: value.into(), localized: true } + Self { raw: value.into(), localized: true, display: None } + } + + fn paper(paper: &PaperSize) -> Self { + Self { + raw: paper.canonical.to_string(), + localized: false, + display: Some(paper.display()), + } } } impl fmt::Display for PlotChoice { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.localized { + if let Some(display) = &self.display { + formatter.write_str(display) + } else if self.localized { if let Some(name) = self.raw.strip_prefix("View: ") { formatter.write_str(crate::tf!("View: {name}").as_ref()) } else { @@ -60,7 +77,45 @@ impl fmt::Display for PlotChoice { #[cfg(test)] mod tests { - use super::PlotChoice; + use super::{CustomPaperDraft, CustomPaperError, PlotChoice}; + use crate::io::paper_catalog::{resolve, Margins, PaperUnits}; + + #[test] + fn custom_sheet_draft_validates_and_builds() { + let mut draft = CustomPaperDraft { + name: " Roll 24 ".into(), + width: "1500".into(), + height: "609,6".into(), + inches: false, + margins: ["5".into(), "17".into(), "".into(), "abc".into()], + error: None, + }; + let custom = draft.build().expect("valid sheet"); + assert_eq!(custom.name, "Roll 24"); + assert_eq!((custom.width, custom.height), (1500.0, 609.6)); + assert_eq!(custom.units, PaperUnits::Millimeters); + // Blank or unparsable margins mean no margin, never a refusal. + assert_eq!(custom.margins, Margins { left: 5.0, bottom: 17.0, right: 0.0, top: 0.0 }); + assert_eq!(custom.canonical(), "Roll_24_(609.60_x_1500.00_MM)"); + draft.width = "0".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + draft.width = "x".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + } + + #[test] + fn custom_sheet_draft_seeds_from_a_sheet_in_its_own_unit() { + let arch_d = resolve("ARCH_D_(24.00_x_36.00_Inches)").unwrap(); + let draft = CustomPaperDraft::from_paper(&arch_d, Margins::uniform(6.35)); + assert_eq!(draft.name, "ARCH D"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("24", "36")); + assert!(draft.inches); + assert_eq!(draft.margins, ["0.25", "0.25", "0.25", "0.25"]); + let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(); + let draft = CustomPaperDraft::from_paper(&a4, Margins { left: 5.0, bottom: 17.0, right: 6.0, top: 18.0 }); + assert!(!draft.inches); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + } #[test] fn named_view_display_preserves_the_saved_plot_area() { @@ -98,8 +153,26 @@ pub enum PlotDlgMsg { Commit, Preview, PrinterProperties, + /// The driver options of a printer arrived (or failed to), for the + /// in-line properties editor; carries the printer's name so a late + /// answer for a printer the user has since left is ignored. + PrinterOptionsLoaded(String, Result, String>), + /// One option of the properties editor was set to a choice keyword. + PrinterOptionSet(String, String), + /// Keep the editor's choices for the printer and close it. + PrinterOptionsApply, + /// Put every option back to the driver's default. + PrinterOptionsReset, + /// Close the editor without keeping its changes. + PrinterOptionsCancel, Printer(String), + /// A printer answered (or failed) the media query started when it was + /// selected; carries the printer's name so a stale answer for a printer + /// the user has since left is ignored. + PrinterMedia(String, Option>), Paper(String), + /// Edits of the inline "custom paper size" editor under the Size row. + CustomPaper(CustomPaperMsg), Orientation(String), Area(String), Scale(String), @@ -134,6 +207,151 @@ pub enum PlotDlgMsg { NameCancel, } +/// Which side of the printable-margin row a value belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarginSide { + Left, + Bottom, + Right, + Top, +} + +#[derive(Debug, Clone)] +pub enum CustomPaperMsg { + /// Open the editor, seeded from the selected sheet. + Open, + Cancel, + Name(String), + Width(String), + Height(String), + /// Units picked from the dropdown, by their catalogue label. + Units(String), + Margin(MarginSide, String), + /// Add (or replace) the sheet and select it. + Add, + /// Remove the selected user-defined sheet. + Remove, +} + +/// The inline custom-sheet editor's fields, kept as typed text so a half-typed +/// number does not snap to a value while editing. Everything is in the +/// draft's own units. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CustomPaperDraft { + pub name: String, + pub width: String, + pub height: String, + pub inches: bool, + /// Left, bottom, right, top. + pub margins: [String; 4], + /// Why the last Add was refused, shown under the fields. + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomPaperError { + /// Width or height is not a positive number. + Size, +} + +impl CustomPaperDraft { + fn units(&self) -> PaperUnits { + if self.inches { + PaperUnits::Inches + } else { + PaperUnits::Millimeters + } + } + + /// Seed the editor from a sheet: its dimensions and unit, and the margins + /// given (in millimetres) converted into that unit. + pub fn from_paper(paper: &PaperSize, margins_mm: Margins) -> Self { + let units = paper.units; + let fmt = |v: f64| { + let v = units.from_mm(v); + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + let dim = |v: f64| { + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + Self { + name: paper.label.to_string(), + width: dim(paper.width), + height: dim(paper.height), + inches: units == PaperUnits::Inches, + margins: [ + fmt(margins_mm.left), + fmt(margins_mm.bottom), + fmt(margins_mm.right), + fmt(margins_mm.top), + ], + error: None, + } + } + + /// The sheet the fields describe, or the reason they do not describe one. + pub fn build(&self) -> Result { + let number = |text: &str| text.trim().replace(',', ".").parse::().ok(); + let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else { + return Err(CustomPaperError::Size); + }; + if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) { + return Err(CustomPaperError::Size); + } + let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + Ok(CustomPaper { + name: self.name.trim().to_string(), + width, + height, + units: self.units(), + margins: Margins { + left: margin(&self.margins[0]), + bottom: margin(&self.margins[1]), + right: margin(&self.margins[2]), + top: margin(&self.margins[3]), + }, + }) + } +} + +/// The in-line printer-properties editor: the printer it belongs to, the +/// driver's options, and the choice currently shown for each. +#[derive(Debug, Clone, PartialEq)] +pub struct PrinterOptionsDraft { + pub printer: String, + /// `None` while the options are still being fetched. + pub options: Option>, + /// Current choice keyword per option key. + pub choices: std::collections::BTreeMap, + /// Why the options could not be listed, when they could not. + pub error: Option, +} + +impl PrinterOptionsDraft { + /// The choices that differ from the driver's defaults — all a job needs + /// to pass along. + pub fn overrides(&self) -> std::collections::BTreeMap { + let Some(options) = &self.options else { + return std::collections::BTreeMap::new(); + }; + options + .iter() + .filter_map(|option| { + let choice = self.choices.get(&option.key)?; + (choice != &option.default).then(|| (option.key.clone(), choice.clone())) + }) + .collect() + } +} + /// Transient state backing the Plot dialog. Seeded from the layout's plot /// settings when the dialog opens; consumed on commit. // The persisted fields form the "plot" section of the app config @@ -147,6 +365,26 @@ pub struct PlotDialogState { /// sentinels. #[serde(skip)] pub printers: Vec, + /// Name of the system default printer, shown next to the default entry. + #[serde(skip)] + pub default_printer: Option, + /// Sheets and printable areas the selected printer reported; `None` + /// while unknown, when the platform cannot ask, or for PDF output. + #[serde(skip)] + pub printer_media: Option>, + /// Sheets the user defined, offered for every device and remembered + /// across sessions. + pub custom_papers: Vec, + /// The inline custom-sheet editor while it is open. + #[serde(skip)] + pub custom_editor: Option, + /// Driver options chosen per printer (`printer → key → choice`), the way + /// a plotter configuration remembers its device settings; applied to + /// every job sent to that printer and kept across sessions. + pub driver_options: std::collections::BTreeMap>, + /// The in-line printer-properties editor while it is open. + #[serde(skip)] + pub printer_editor: Option, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -210,9 +448,15 @@ impl Default for PlotDialogState { fn default() -> Self { Self { printers: Vec::new(), + default_printer: None, + printer_media: None, + custom_papers: Vec::new(), + custom_editor: None, + driver_options: std::collections::BTreeMap::new(), + printer_editor: None, printer: None, to_file: false, - paper: "A4".into(), + paper: paper_catalog::default_paper().canonical.to_string(), paper_width_mm: 297.0, paper_height_mm: 210.0, orientation: "Landscape".into(), @@ -290,80 +534,19 @@ fn legacy_fit_to_paper_default() -> bool { } fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |theme: &Theme, st| { - let palette = theme.palette(); - let pair = match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, - (false, button::Status::Hovered | button::Status::Pressed) => { - palette.background.strong - } - (true, _) => palette.primary.base, - _ => palette.background.weak, - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - } - } -} - -fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { - let palette = theme.palette(); - let border = match status { - text_input::Status::Focused { .. } => palette.primary.base.color, - _ => palette.background.neutral.color, - }; - text_input::Style { - background: Background::Color(palette.background.base.color), - border: Border { color: border, width: 1.0, radius: 3.0.into() }, - icon: palette.background.base.text, - placeholder: palette.background.base.text.scale_alpha(0.48), - value: palette.background.base.text, - selection: palette.primary.base.color.scale_alpha(0.5), - } -} - -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } + form::button_style(accent) } fn hdivider<'a>(width: Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::hdivider(width) } fn section_label<'a>(s: Cow<'static, str>) -> Element<'a, Message> { - text(s).size(11).style(muted_style).into() + form::section_label(s) } fn vsep<'a>(height: Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::vseparator(height) } fn setup_row<'a>( @@ -376,7 +559,7 @@ fn setup_row<'a>( return text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Fit) @@ -414,15 +597,7 @@ fn drop_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: Length, ) -> Element<'a, Message> { - let pl = iced::widget::pick_list(selected, options, |value| value.to_string()) - .on_select(move |choice| Message::PlotDlg(ctor(choice.raw))) - .text_size(12) - .padding([3, 6]) - .width(width); - row![text(label).size(11).style(muted_style).width(92), pl] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list(label, options, selected, move |choice| Message::PlotDlg(ctor(choice.raw)), width) } fn drop_row_enabled<'a>( @@ -433,20 +608,14 @@ fn drop_row_enabled<'a>( width: Length, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return drop_row(label, options, selected, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field( - selected.map(|choice| choice.to_string()).unwrap_or_default().as_str(), - 12.0, - width, - ), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list_enabled( + label, + options, + selected, + move |choice| Message::PlotDlg(ctor(choice.raw)), + width, + enabled, + ) } /// A `label : text field` row. @@ -456,17 +625,7 @@ fn field_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: u16, ) -> Element<'a, Message> { - row![ - text(label).size(11).style(muted_style).width(92), - text_input("", value) - .on_input(move |s| Message::PlotDlg(ctor(s))) - .style(field_style) - .size(12) - .width(width as f32), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32) } fn field_row_enabled<'a>( @@ -476,19 +635,9 @@ fn field_row_enabled<'a>( width: u16, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return field_row(label, value, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field(value, 12.0, Length::Fixed(width as f32)), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field_enabled(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32, enabled) } -/// A single option checkbox bound to a `PlotFlag`. fn check<'a>(label: Cow<'static, str>, on: bool, flag: PlotFlag) -> Element<'a, Message> { checkbox(on) .label(label) @@ -525,6 +674,196 @@ fn choices(items: &[&str]) -> Vec { items.iter().map(|&value| PlotChoice::localized(value)).collect() } +/// The in-line printer-properties editor: one `label : choice` row per +/// driver option (media type, colour mode, quality, duplex, trays…), with +/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a +/// CUPS printer, without leaving the plot dialog. +fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> { + let mut rows = column![section_label(Cow::Owned(crate::tf!( + "Printer properties — {printer}", + printer = draft.printer + ).into_owned()))] + .spacing(6); + match (&draft.options, &draft.error) { + (Some(options), _) if options.is_empty() => { + rows = rows.push( + text(t!("This printer reports no driver options.")) + .size(10) + .style(muted_style), + ); + } + (Some(options), _) => { + for option in options { + let current = draft + .choices + .get(&option.key) + .cloned() + .unwrap_or_else(|| option.default.clone()); + let choices: Vec = option + .choices + .iter() + .map(|choice| PlotChoice { + raw: choice.keyword.clone(), + localized: false, + display: Some(choice.label.clone()), + }) + .collect(); + let selected = choices.iter().find(|c| c.raw == current).cloned(); + let key = option.key.clone(); + rows = rows.push(form::labeled_pick_list( + Cow::Owned(option.label.clone()), + choices, + selected, + move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)), + Length::Fill, + )); + } + } + (None, Some(error)) => { + rows = rows.push( + text(crate::tf!("Could not read the printer's options: {error}")) + .size(10) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }), + ); + } + (None, None) => { + rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style)); + } + } + let mut buttons = row![ + button(text(t!("Reset")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset)) + .style(btn(false)) + .padding([4, 10]), + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel)) + .style(btn(false)) + .padding([4, 10]), + ] + .spacing(6); + if draft.options.is_some() { + buttons = buttons.push( + button(text(t!("Apply")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply)) + .style(btn(true)) + .padding([4, 12]), + ); + } + rows = rows.push(buttons); + container(rows) + .padding(8) + .width(width) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + +/// The inline editor for a user-defined sheet: name, dimensions and unit, +/// printable margins, Add / Cancel. Numbers stay text until Add validates +/// them. Every margin field carries its own side label so the four values +/// cannot be confused, and the unit is repeated where the numbers are typed. +fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> { + let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m)); + let unit = if draft.inches { "in" } else { "mm" }; + let unit_options: Vec = vec![ + PlotChoice::localized("Millimeters"), + PlotChoice::localized("Inches"), + ]; + let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" })); + const SIDE_LABEL: f32 = 52.0; + const NUMBER: f32 = 64.0; + let dimension = |label: Cow<'static, str>, value: &'a str, ctor: fn(String) -> CustomPaperMsg| { + form::labeled_field_compact(label, value, move |v| msg(ctor(v)), SIDE_LABEL, NUMBER) + }; + let margin = |label: Cow<'static, str>, side: MarginSide, index: usize| { + form::labeled_field_compact( + label, + &draft.margins[index], + move |v| msg(CustomPaperMsg::Margin(side, v)), + SIDE_LABEL, + NUMBER, + ) + }; + let error: Element<'_, Message> = match draft.error { + Some(CustomPaperError::Size) => text(t!("Enter a positive width and height.")) + .size(10) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }) + .into(), + None => Space::new().height(0).into(), + }; + container( + column![ + section_label(t!("Custom paper size")), + form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 200.0), + form::labeled_pick_list( + t!("Units"), + unit_options, + unit_selected, + move |choice| msg(CustomPaperMsg::Units(choice.raw)), + Length::Fixed(150.0), + ), + row![ + dimension(t!("Width"), &draft.width, CustomPaperMsg::Width), + dimension(t!("Height"), &draft.height, CustomPaperMsg::Height), + text(unit).size(11).style(muted_style), + ] + .spacing(14) + .align_y(iced::Center), + text(format!("{} ({unit})", t!("Printable margins"))) + .size(11) + .style(muted_style), + row![ + margin(t!("Left"), MarginSide::Left, 0), + margin(t!("Right"), MarginSide::Right, 2), + ] + .spacing(14), + row![ + margin(t!("Top"), MarginSide::Top, 3), + margin(t!("Bottom"), MarginSide::Bottom, 1), + ] + .spacing(14), + error, + row![ + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(11)) + .on_press(msg(CustomPaperMsg::Cancel)) + .style(btn(false)) + .padding([4, 10]), + button(text(t!("Add")).size(11)) + .on_press(msg(CustomPaperMsg::Add)) + .style(btn(true)) + .padding([4, 12]), + ] + .spacing(6), + ] + .spacing(6), + ) + .padding(8) + .width(width) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.weak.color)), + border: Border { + color: theme.palette().background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }) + .into() +} + pub fn view_window( s: &PlotDialogState, print_all_options: bool, @@ -571,7 +910,7 @@ pub fn view_window( text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Length::Fill), @@ -648,7 +987,17 @@ pub fn view_window( .spacing(4); // ── Printer / plotter ───────────────────────────────────────────────── - let mut printer_opts = vec![PlotChoice::localized(OUT_DEFAULT)]; + // The default entry names the printer it resolves to, when known, so the + // user sees where a plot will go without leaving the dialog. + let default_entry = match &s.default_printer { + Some(name) => PlotChoice { + raw: OUT_DEFAULT.to_string(), + localized: true, + display: Some(format!("{} ({name})", crate::i18n::translate(OUT_DEFAULT))), + }, + None => PlotChoice::localized(OUT_DEFAULT), + }; + let mut printer_opts = vec![default_entry.clone()]; printer_opts.extend(s.printers.iter().cloned().map(PlotChoice::raw)); printer_opts.push(PlotChoice::localized(OUT_PDF)); let printer_sel = if s.to_file { @@ -656,13 +1005,44 @@ pub fn view_window( } else { Some(match &s.printer { Some(printer) => PlotChoice::raw(printer.clone()), - None => PlotChoice::localized(OUT_DEFAULT), + None => default_entry, }) }; - let mut paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); - if !paper_opts.iter().any(|name| name == &s.paper) { - paper_opts.push(s.paper.clone()); + // A printer that reported its media lists those sheets (with the + // printable areas it will actually honour); PDF output and printers that + // could not be asked list the catalogue by series. A sheet that only + // exists in the drawing (a driver's custom size, an old config's bare + // name) is added at the top so the current selection stays selectable. + let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm); + let printer_sheets = (!s.to_file).then_some(()).and(s.printer_media.as_deref()); + let mut paper_opts: Vec = match printer_sheets { + Some(caps) => caps.media.iter().map(|media| PlotChoice::paper(&media.paper)).collect(), + None => paper_catalog::catalog().iter().map(PlotChoice::paper).collect(), + }; + // User-defined sheets come last, after whatever the device offers. + for custom in &s.custom_papers { + let choice = PlotChoice::paper(&custom.paper()); + if !paper_opts.contains(&choice) { + paper_opts.push(choice); + } } + let paper_sel = PlotChoice::paper(&selected_paper); + if !paper_opts.contains(&paper_sel) { + paper_opts.insert(0, paper_sel.clone()); + } + let selected_is_custom = s + .custom_papers + .iter() + .any(|custom| custom.canonical() == selected_paper.canonical); + let paper_source_note: Element<'_, Message> = if printer_sheets.is_some() { + text(t!("Sheet sizes and printable areas reported by the printer.")) + .size(10) + .style(muted_style) + .width(width) + .into() + } else { + Space::new().height(0).into() + }; let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) .size(10) @@ -672,7 +1052,11 @@ pub fn view_window( } else { Space::new().height(0).into() }; - let mut output_row = row![ + // Heading and picker share one line — the section has nothing else to + // announce — and the picker takes the whole row so a long queue name + // stays readable; the driver's Properties… button sits beneath it. + let output_field = row![ + text(t!("Printer / plotter")).size(11).style(muted_style).width(form::LABEL_WIDTH), iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string()) .on_select(|choice| Message::PlotDlg(PlotDlgMsg::Printer(choice.raw))) .text_size(12) @@ -680,45 +1064,70 @@ pub fn view_window( .width(Length::Fill), ] .spacing(8) - .align_y(iced::Center); - if !s.to_file { - output_row = output_row.push( + .align_y(iced::Center) + .width(Length::Fill); + let printer_controls: Element<'_, Message> = if s.to_file { + Space::new().height(0).into() + } else { + row![ + field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60), + Space::new().width(Length::Fill), button(text(t!("Properties…")).size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties)) .style(btn(false)) - .padding([4, 8]), - ); - } - let output_field = column![ - text(t!("Output")).size(11).style(muted_style), - output_row, - ] - .spacing(4) - .width(Length::Fill); - let copies_row: Element<'_, Message> = if s.to_file { - Space::new().height(0).into() - } else { - field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60) - }; - let printer_panel = panel( - column![ - section_label(t!("Printer / plotter")), - output_field, - copies_row, + .padding([4, 10]), ] - .spacing(7), - ); + .align_y(iced::Center) + .into() + }; + // A printer with remembered driver choices says so under its row; the + // editor itself unfolds there when Properties… is pressed. + let printer_note: Element<'_, Message> = match (&s.printer_editor, &s.printer) { + (Some(draft), _) => printer_options_editor(draft, width), + (None, Some(printer)) if s.driver_options.get(printer).is_some_and(|o| !o.is_empty()) => { + let count = s.driver_options[printer].len(); + text(crate::tf!("{count} driver option(s) set for this printer.")) + .size(10) + .style(muted_style) + .into() + } + _ => Space::new().height(0).into(), + }; + let printer_panel = panel(column![output_field, printer_controls, printer_note].spacing(7)); // ── Paper, area, offset, scale ──────────────────────────────────────── + // The picker takes the whole row so long media names stay readable; the + // custom-sheet buttons sit right-aligned beneath it, and the editor + // unfolds in their place. + let size_row = drop_row(t!("Paper size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill); + let custom_controls: Element<'_, Message> = match &s.custom_editor { + Some(draft) => custom_paper_editor(draft, width), + None => { + let mut buttons = row![Space::new().width(Length::Fill)].spacing(6); + if selected_is_custom { + buttons = buttons.push( + button(text(t!("Remove")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove))) + .style(btn(false)) + .padding([4, 10]), + ); + } + buttons + .push( + button(text(t!("Custom…")).size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open))) + .style(btn(false)) + .padding([4, 10]), + ) + .into() + } + }; + // Like the printer row: the section is its one picker, so heading and + // control share a line. let paper_panel = panel(column![ - section_label(t!("Paper")), - drop_row( - t!("Size"), - paper_opts.into_iter().map(PlotChoice::raw).collect(), - Some(PlotChoice::raw(s.paper.clone())), - PlotDlgMsg::Paper, - width, - ), + size_row, + custom_controls, + paper_source_note, paper_note, ].spacing(7));