forked from 1b-agent/opencadstudio-upstream
feat(plot): complete plot configuration
- unify Model and Paper Space plot area behavior - add CTB discovery, read/write, and bundled defaults - apply scale, render mode, and output options consistently
This commit is contained in:
1 parent
6705e65e81
commit
325cd2474c
11 files changed
+1141
-600
No files matched your search
+8
-6
@@ -776,14 +776,16 @@ mod save_failure_tests {
|
||||
|
||||
/// Show a file-open dialog and load the selected CTB or STB file.
|
||||
pub async fn pick_plot_style() -> Option<plot_style::PlotStyleTable> {
|
||||
let handle = crate::sys::file_dialog()
|
||||
let mut dialog = crate::sys::file_dialog()
|
||||
.set_title("Load Plot Style Table")
|
||||
.add_filter("Plot Style Tables", &["ctb", "stb", "CTB", "STB"])
|
||||
.add_filter("Plot Style Tables", &["ctb", "CTB"])
|
||||
.add_filter("CTB Files", &["ctb", "CTB"])
|
||||
.add_filter("STB Files", &["stb", "STB"])
|
||||
.add_filter("All Files", &["*"])
|
||||
.pick_file()
|
||||
.await?;
|
||||
.add_filter("All Files", &["*"]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Ok(dir) = plot_style::ensure_plot_styles_dir() {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
let handle = dialog.pick_file().await?;
|
||||
plot_style::PlotStyleTable::load(&crate::sys::handle_path(&handle)).ok()
|
||||
}
|
||||
|
||||
|
||||
+48
-25
@@ -15,9 +15,10 @@ use crate::scene::model::hatch_model::HatchPattern;
|
||||
use crate::scene::WireModel;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use printpdf::{
|
||||
BuiltinFont, Color, Line, LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op,
|
||||
PaintMode, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing,
|
||||
Pt, Rgb, TextItem, WindingOrder,
|
||||
BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line,
|
||||
LineCapStyle, LineDashPattern, LineJoinStyle, LinePoint, Mm, Op, PaintMode, PdfDocument,
|
||||
PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem,
|
||||
WindingOrder,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::Write;
|
||||
@@ -67,9 +68,8 @@ pub struct PdfPlotOptions {
|
||||
pub object_lineweights: bool,
|
||||
pub scale_lineweights: bool,
|
||||
pub transparency: bool,
|
||||
pub monochrome: bool,
|
||||
pub wireframe: bool,
|
||||
pub stamp: bool,
|
||||
pub merge_lines: bool,
|
||||
pub group_splits: PlotGroupSplits,
|
||||
}
|
||||
|
||||
@@ -87,9 +87,8 @@ impl Default for PdfPlotOptions {
|
||||
object_lineweights: true,
|
||||
scale_lineweights: false,
|
||||
transparency: false,
|
||||
monochrome: false,
|
||||
wireframe: false,
|
||||
stamp: false,
|
||||
merge_lines: false,
|
||||
group_splits: PlotGroupSplits::default(),
|
||||
}
|
||||
}
|
||||
@@ -195,6 +194,20 @@ fn build_pdf(
|
||||
rectangle: printpdf::Rect::from_wh(Mm(paper_w).into(), Mm(paper_h).into()),
|
||||
});
|
||||
|
||||
let normal_blend = if options.merge_lines {
|
||||
let merge = doc.add_graphics_state(
|
||||
ExtendedGraphicsState::default().with_blend_mode(BlendMode::multiply()),
|
||||
);
|
||||
let normal = doc.add_graphics_state(
|
||||
ExtendedGraphicsState::default().with_blend_mode(BlendMode::normal()),
|
||||
);
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
ops.push(Op::LoadGraphicsState { gs: merge });
|
||||
Some(normal)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Round line caps/joins for CAD aesthetics.
|
||||
ops.push(Op::SetLineCapStyle {
|
||||
cap: LineCapStyle::Round,
|
||||
@@ -283,7 +296,15 @@ fn build_pdf(
|
||||
|
||||
// Hatch / wipeout fills render before wires so linework stays visible.
|
||||
for hatch in wipeouts.iter().chain(hatches.iter()) {
|
||||
emit_hatch(&mut ops, hatch, ox, oy, plot_style, options);
|
||||
emit_hatch(
|
||||
&mut ops,
|
||||
hatch,
|
||||
ox,
|
||||
oy,
|
||||
plot_style,
|
||||
options,
|
||||
normal_blend.as_ref(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut last_color: Option<[f32; 3]> = None;
|
||||
@@ -337,7 +358,7 @@ fn build_pdf(
|
||||
b = 0.50;
|
||||
}
|
||||
}
|
||||
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
|
||||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||||
|
||||
if last_color
|
||||
.map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01)
|
||||
@@ -437,6 +458,9 @@ fn build_pdf(
|
||||
if needs_state {
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
}
|
||||
if options.merge_lines {
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
}
|
||||
if options.stamp {
|
||||
emit_plot_stamp(&mut ops);
|
||||
}
|
||||
@@ -488,15 +512,11 @@ fn flush_line(ops: &mut Vec<Op>, pts: &[LinePoint]) {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn plotted_color(
|
||||
mut rgb: [f32; 3],
|
||||
rgb: [f32; 3],
|
||||
alpha: f32,
|
||||
screening: f32,
|
||||
options: PdfPlotOptions,
|
||||
preserve_white: bool,
|
||||
) -> [f32; 3] {
|
||||
if options.monochrome && !preserve_white {
|
||||
rgb = [0.0, 0.0, 0.0];
|
||||
}
|
||||
let amount = screening.clamp(0.0, 1.0)
|
||||
* if options.transparency {
|
||||
alpha.clamp(0.0, 1.0)
|
||||
@@ -520,7 +540,7 @@ fn emit_wire_fills(
|
||||
options: PdfPlotOptions,
|
||||
) {
|
||||
for wire in wires {
|
||||
if wire.fill_tris.is_empty() || (options.wireframe && wire.fill_is_3d) {
|
||||
if wire.fill_tris.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let [mut r, mut g, mut b, a] = wire.color;
|
||||
@@ -541,7 +561,7 @@ fn emit_wire_fills(
|
||||
if !color_overridden {
|
||||
[r, g, b] = adapt_text_color([r, g, b]);
|
||||
}
|
||||
[r, g, b] = plotted_color([r, g, b], a, screening, options, false);
|
||||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||||
ops.push(Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb {
|
||||
r,
|
||||
@@ -623,6 +643,7 @@ fn emit_hatch(
|
||||
oy: f64,
|
||||
plot_style: Option<&PlotStyleTable>,
|
||||
options: PdfPlotOptions,
|
||||
normal_blend: Option<&ExtendedGraphicsStateId>,
|
||||
) {
|
||||
if hatch.boundary.is_empty() {
|
||||
return;
|
||||
@@ -672,13 +693,7 @@ fn emit_hatch(
|
||||
b = 0.50;
|
||||
}
|
||||
}
|
||||
[r, g, b] = plotted_color(
|
||||
[r, g, b],
|
||||
a,
|
||||
screening,
|
||||
options,
|
||||
is_wipeout,
|
||||
);
|
||||
[r, g, b] = plotted_color([r, g, b], a, screening, options);
|
||||
// `boundary` holds f32 offsets from the f64 `world_origin`, so resolve the
|
||||
// pair in f64 and only narrow once the offset has cancelled — casting
|
||||
// `world_origin` to f32 first re-introduces the ~0.5 m UTM quantisation the
|
||||
@@ -728,7 +743,6 @@ fn emit_hatch(
|
||||
color2[3],
|
||||
1.0,
|
||||
options,
|
||||
false,
|
||||
);
|
||||
let avg = [
|
||||
(r + second[0]) * 0.5,
|
||||
@@ -789,6 +803,12 @@ fn emit_hatch(
|
||||
}),
|
||||
});
|
||||
}
|
||||
if is_wipeout {
|
||||
if let Some(gs) = normal_blend {
|
||||
ops.push(Op::SaveGraphicsState);
|
||||
ops.push(Op::LoadGraphicsState { gs: gs.clone() });
|
||||
}
|
||||
}
|
||||
ops.push(Op::DrawPolygon {
|
||||
polygon: Polygon {
|
||||
rings,
|
||||
@@ -796,6 +816,9 @@ fn emit_hatch(
|
||||
winding_order: WindingOrder::EvenOdd,
|
||||
},
|
||||
});
|
||||
if is_wipeout && normal_blend.is_some() {
|
||||
ops.push(Op::RestoreGraphicsState);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Text (SDF glyph quads → vector strokes / fills) ────────────────────────
|
||||
@@ -908,7 +931,7 @@ fn emit_text(
|
||||
let rgb = ctb_color.unwrap_or_else(|| {
|
||||
adapt_text_color([quad[0].color[0], quad[0].color[1], quad[0].color[2]])
|
||||
});
|
||||
let [r, g, b] = plotted_color(rgb, a, screening, options, false);
|
||||
let [r, g, b] = plotted_color(rgb, a, screening, options);
|
||||
|
||||
// Quad corners in world XY: verts run [bl, br, tr, bl, tr, tl].
|
||||
let bl = glyph_world_xy(&quad[0]);
|
||||
|
||||
+491
-116
@@ -3,23 +3,84 @@
|
||||
//! CTB files map indexed drawing colors (ACI, 1-255) to pen properties:
|
||||
//! RGB color override, lineweight, and screeing percentage.
|
||||
//!
|
||||
//! File format: deflate-compressed text (key = value pairs) with
|
||||
//! 255 `begin_plot_style … end_plot_style` blocks.
|
||||
//! File format: a fixed 60-byte header followed by zlib-compressed text.
|
||||
//!
|
||||
//! STB files follow the same format but use named styles instead of
|
||||
//! ACI indices; they are read into a `Vec<NamedPlotStyle>`.
|
||||
|
||||
use rustc_hash::FxHashMap as HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb";
|
||||
pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb";
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn plot_styles_dir() -> Result<PathBuf, String> {
|
||||
crate::config::config_dir()
|
||||
.map(|path| path.join("plotstyles"))
|
||||
.ok_or_else(|| "Plot styles folder could not be resolved".to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn ensure_plot_styles_dir() -> Result<PathBuf, String> {
|
||||
let dir = plot_styles_dir()?;
|
||||
std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?;
|
||||
for (name, bytes) in [
|
||||
(
|
||||
DEFAULT_PLOT_STYLE,
|
||||
include_bytes!("../../assets/plotstyles/ocad.ctb").as_slice(),
|
||||
),
|
||||
(
|
||||
MONOCHROME_PLOT_STYLE,
|
||||
include_bytes!("../../assets/plotstyles/monochrome.ctb").as_slice(),
|
||||
),
|
||||
] {
|
||||
let path = dir.join(name);
|
||||
if !path.exists() {
|
||||
std::fs::write(path, bytes).map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// CTB files available to the Plot dialog.
|
||||
pub fn available_ctb_names() -> Vec<String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Ok(dir) = ensure_plot_styles_dir() else {
|
||||
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()];
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
|
||||
.filter_map(|entry| {
|
||||
let path = entry.path();
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
|
||||
.then(|| entry.file_name().to_string_lossy().into_owned())
|
||||
})
|
||||
.collect();
|
||||
names.sort_by_key(|name| name.to_ascii_lowercase());
|
||||
names.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
|
||||
names
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]
|
||||
}
|
||||
}
|
||||
|
||||
// ── Standard lineweight table (index → mm) ───────────────────────────────────
|
||||
|
||||
/// Lineweight table: index value → mm, matching the stored LWEIGHT codes.
|
||||
/// Index 0 = 0.00 mm (hairline), others follow the DXF lineweight enum.
|
||||
pub const LW_TABLE: &[f32] = &[
|
||||
0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.50, 0.53, 0.60, 0.70,
|
||||
0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
|
||||
0.00, 0.05, 0.09, 0.10, 0.13, 0.15, 0.18, 0.20, 0.25, 0.30, 0.35, 0.40, 0.45, 0.50,
|
||||
0.53, 0.60, 0.65, 0.70, 0.80, 0.90, 1.00, 1.06, 1.20, 1.40, 1.58, 2.00, 2.11,
|
||||
];
|
||||
|
||||
// ── Per-color entry ───────────────────────────────────────────────────────────
|
||||
@@ -27,24 +88,45 @@ pub const LW_TABLE: &[f32] = &[
|
||||
/// A single entry in a CTB or STB plot style table.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlotStyleEntry {
|
||||
/// Optional display name (empty for CTB; the style name for STB).
|
||||
pub name: String,
|
||||
pub localized_name: String,
|
||||
pub description: String,
|
||||
/// If `Some([r,g,b])`, override the entity color with this RGB value (0..255).
|
||||
/// If `None`, use the object color.
|
||||
pub color: Option<[u8; 3]>,
|
||||
/// Lineweight index into `LW_TABLE`. 255 = use object lineweight.
|
||||
/// Lineweight index into the table. 0 (and legacy 255) = object lineweight.
|
||||
pub lineweight: u8,
|
||||
/// Screen percentage 0–100 (100 = opaque).
|
||||
pub screening: u8,
|
||||
pub color_policy: u8,
|
||||
pub physical_pen_number: u16,
|
||||
pub virtual_pen_number: u16,
|
||||
pub linepattern_size: f32,
|
||||
pub linetype: u8,
|
||||
pub adaptive_linetype: bool,
|
||||
pub fill_style: u8,
|
||||
pub end_style: u8,
|
||||
pub join_style: u8,
|
||||
}
|
||||
|
||||
impl Default for PlotStyleEntry {
|
||||
fn default() -> Self {
|
||||
PlotStyleEntry {
|
||||
name: String::new(),
|
||||
localized_name: String::new(),
|
||||
description: String::new(),
|
||||
color: None,
|
||||
lineweight: 255, // use object lineweight
|
||||
lineweight: 0,
|
||||
screening: 100,
|
||||
color_policy: 1,
|
||||
physical_pen_number: 0,
|
||||
virtual_pen_number: 0,
|
||||
linepattern_size: 0.5,
|
||||
linetype: 31,
|
||||
adaptive_linetype: true,
|
||||
fill_style: 73,
|
||||
end_style: 4,
|
||||
join_style: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +141,11 @@ pub struct PlotStyleTable {
|
||||
pub name: String,
|
||||
/// Whether this is a named-style (STB) table rather than color-based (CTB).
|
||||
pub is_stb: bool,
|
||||
pub description: String,
|
||||
pub scale_factor: f32,
|
||||
pub apply_factor: bool,
|
||||
pub custom_lineweight_display_units: u8,
|
||||
pub lineweights: Vec<f32>,
|
||||
/// For CTB: entries indexed by ACI (index 0 unused; 1..=255 are valid).
|
||||
pub aci_entries: Vec<PlotStyleEntry>, // 256 entries, index = ACI
|
||||
/// For STB: named style entries.
|
||||
@@ -71,6 +158,11 @@ impl PlotStyleTable {
|
||||
PlotStyleTable {
|
||||
name: name.into(),
|
||||
is_stb: false,
|
||||
description: String::new(),
|
||||
scale_factor: 1.0,
|
||||
apply_factor: false,
|
||||
custom_lineweight_display_units: 0,
|
||||
lineweights: LW_TABLE.to_vec(),
|
||||
aci_entries: (0..=255).map(|_| PlotStyleEntry::default()).collect(),
|
||||
named_entries: HashMap::default(),
|
||||
}
|
||||
@@ -84,11 +176,63 @@ impl PlotStyleTable {
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let is_stb = name.to_lowercase().ends_with(".stb");
|
||||
let text = decompress_ctb(&raw)?;
|
||||
Self::from_bytes(name, &raw)
|
||||
}
|
||||
|
||||
pub fn from_bytes(name: impl Into<String>, raw: &[u8]) -> Result<Self, String> {
|
||||
let name = name.into();
|
||||
let is_stb = name.to_ascii_lowercase().ends_with(".stb");
|
||||
let text = decompress_ctb(raw)?;
|
||||
parse_plot_style_text(&text, name, is_stb)
|
||||
}
|
||||
|
||||
pub fn builtin(name: &str) -> Result<Self, String> {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
DEFAULT_PLOT_STYLE => Self::from_bytes(
|
||||
DEFAULT_PLOT_STYLE,
|
||||
include_bytes!("../../assets/plotstyles/ocad.ctb"),
|
||||
),
|
||||
MONOCHROME_PLOT_STYLE => Self::from_bytes(
|
||||
MONOCHROME_PLOT_STYLE,
|
||||
include_bytes!("../../assets/plotstyles/monochrome.ctb"),
|
||||
),
|
||||
_ => Err(format!("Unknown built-in plot style: {name}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load one CTB by file name from the per-user plot styles folder.
|
||||
pub fn load_named(name: &str) -> Result<Self, String> {
|
||||
let path = Path::new(name);
|
||||
if path.components().count() != 1
|
||||
|| !matches!(path.components().next(), Some(Component::Normal(_)))
|
||||
|| !path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("ctb"))
|
||||
{
|
||||
return Err(format!("Invalid plot style name: {name}"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = ensure_plot_styles_dir()?;
|
||||
let matched = std::fs::read_dir(&dir)
|
||||
.map_err(|error| error.to_string())?
|
||||
.filter_map(Result::ok)
|
||||
.find(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(name)
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
.ok_or_else(|| format!("Plot style not found: {name}"))?;
|
||||
return Self::load(&matched);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Self::builtin(name)
|
||||
}
|
||||
|
||||
/// Write this table to disk as a CTB/STB file.
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self, path: &Path) -> Result<(), String> {
|
||||
@@ -110,10 +254,10 @@ impl PlotStyleTable {
|
||||
/// Returns None if no override (use object lineweight).
|
||||
pub fn resolve_lineweight(&self, aci: u8) -> Option<f32> {
|
||||
let entry = self.aci_entries.get(aci as usize)?;
|
||||
if entry.lineweight == 255 {
|
||||
if matches!(entry.lineweight, 0 | 255) {
|
||||
None
|
||||
} else {
|
||||
LW_TABLE.get(entry.lineweight as usize).copied()
|
||||
self.lineweights.get(entry.lineweight as usize).copied()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,31 +273,69 @@ impl PlotStyleTable {
|
||||
|
||||
fn to_text(&self) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("description=\n");
|
||||
s.push_str("apply_factor=0\n");
|
||||
s.push_str("unit_type=1\n");
|
||||
s.push_str("custom_lineweight_display_units=0\n");
|
||||
for (_idx, entry) in self.aci_entries.iter().enumerate().skip(1) {
|
||||
s.push_str("begin_plot_style\n");
|
||||
s.push_str(&format!(" description={}\n", entry.description));
|
||||
s.push_str(" physical_pen_number=0\n");
|
||||
s.push_str(" virtual_pen_number=0\n");
|
||||
s.push_str(&format!(" screen={}\n", entry.screening));
|
||||
s.push_str(" linepattern_size=0.5\n");
|
||||
s.push_str(" linetype=31\n");
|
||||
s.push_str(" adaptive_linetype=TRUE\n");
|
||||
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
|
||||
s.push_str(" fill_style=64\n");
|
||||
s.push_str(" end_style=0\n");
|
||||
s.push_str(" join_style=0\n");
|
||||
if let Some([r, g, b]) = entry.color {
|
||||
s.push_str(&format!(" color1=#{:02X}{:02X}{:02X}\n", r, g, b));
|
||||
} else {
|
||||
// 0xC2000000 = "use object color"
|
||||
s.push_str(" color1=-1056964608\n");
|
||||
}
|
||||
s.push_str("end_plot_style\n");
|
||||
let description = self.description.replace(['\r', '\n'], " ");
|
||||
s.push_str(&format!("description=\"{description}\n"));
|
||||
s.push_str("aci_table_available=TRUE\n");
|
||||
s.push_str(&format!("scale_factor={:.1}\n", self.scale_factor));
|
||||
s.push_str(&format!(
|
||||
"apply_factor={}\n",
|
||||
if self.apply_factor { "TRUE" } else { "FALSE" }
|
||||
));
|
||||
s.push_str(&format!(
|
||||
"custom_lineweight_display_units={}\n",
|
||||
self.custom_lineweight_display_units
|
||||
));
|
||||
s.push_str("aci_table{\n");
|
||||
for index in 0..255 {
|
||||
s.push_str(&format!(" {index}=\"Color_{}\n", index + 1));
|
||||
}
|
||||
s.push_str("}\nplot_style{\n");
|
||||
for (index, entry) in self.aci_entries.iter().enumerate().skip(1).take(255) {
|
||||
let style_index = index - 1;
|
||||
let style_name = if entry.name.is_empty() {
|
||||
format!("Color_{index}")
|
||||
} else {
|
||||
entry.name.replace(['\r', '\n'], " ")
|
||||
};
|
||||
let localized_name = if entry.localized_name.is_empty() {
|
||||
style_name.clone()
|
||||
} else {
|
||||
entry.localized_name.replace(['\r', '\n'], " ")
|
||||
};
|
||||
let description = entry.description.replace(['\r', '\n'], " ");
|
||||
s.push_str(&format!(" {style_index}{{\n"));
|
||||
s.push_str(&format!(" name=\"{style_name}\n"));
|
||||
s.push_str(&format!(" localized_name=\"{localized_name}\n"));
|
||||
s.push_str(&format!(" description=\"{description}\n"));
|
||||
if let Some(rgb) = entry.color {
|
||||
let packed = packed_rgb(rgb);
|
||||
s.push_str(&format!(" color={packed}\n mode_color={packed}\n"));
|
||||
} else {
|
||||
s.push_str(" color=-1\n");
|
||||
}
|
||||
s.push_str(&format!(" color_policy={}\n", entry.color_policy));
|
||||
s.push_str(&format!(
|
||||
" physical_pen_number={}\n virtual_pen_number={}\n",
|
||||
entry.physical_pen_number, entry.virtual_pen_number
|
||||
));
|
||||
s.push_str(&format!(" screen={}\n", entry.screening));
|
||||
s.push_str(&format!(
|
||||
" linepattern_size={}\n linetype={}\n adaptive_linetype={}\n",
|
||||
entry.linepattern_size,
|
||||
entry.linetype,
|
||||
if entry.adaptive_linetype { "TRUE" } else { "FALSE" }
|
||||
));
|
||||
s.push_str(&format!(" lineweight={}\n", entry.lineweight));
|
||||
s.push_str(&format!(
|
||||
" fill_style={}\n end_style={}\n join_style={}\n }}\n",
|
||||
entry.fill_style, entry.end_style, entry.join_style
|
||||
));
|
||||
}
|
||||
s.push_str("}\ncustom_lineweight_table{\n");
|
||||
for (index, weight) in self.lineweights.iter().enumerate() {
|
||||
s.push_str(&format!(" {index}={weight:.2}\n"));
|
||||
}
|
||||
s.push_str("}\n");
|
||||
s
|
||||
}
|
||||
}
|
||||
@@ -162,72 +344,290 @@ impl PlotStyleTable {
|
||||
|
||||
/// Decompress a CTB/STB file's raw bytes into the text content.
|
||||
///
|
||||
/// CTB files start with a plain-text header (first line: "PIAFILEVERSION_2.0")
|
||||
/// followed by raw-deflate compressed content. Some tools write pure zlib
|
||||
/// (with the two-byte zlib header 0x78 0x9C) instead — we handle both.
|
||||
fn decompress_ctb(data: &[u8]) -> Result<String, String> {
|
||||
// Find the first newline — everything after it is the compressed payload.
|
||||
let split_at = data
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let payload = &data[split_at..];
|
||||
|
||||
// Try zlib (0x78 prefix) first, then raw deflate.
|
||||
let mut text = String::new();
|
||||
if payload.starts_with(&[0x78]) {
|
||||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
let mut decoded = Vec::new();
|
||||
if data.starts_with(PREFIX) {
|
||||
if data.len() < 60 {
|
||||
return Err("CTB header is truncated".into());
|
||||
}
|
||||
let checksum = u32::from_le_bytes(data[48..52].try_into().unwrap());
|
||||
let text_len = u32::from_le_bytes(data[52..56].try_into().unwrap()) as usize;
|
||||
let compressed_len = u32::from_le_bytes(data[56..60].try_into().unwrap()) as usize;
|
||||
if compressed_len > data.len() - 60 {
|
||||
return Err("CTB compressed payload is truncated".into());
|
||||
}
|
||||
let payload = &data[60..60 + compressed_len];
|
||||
if adler32(payload) != checksum {
|
||||
return Err("CTB compressed payload checksum mismatch".into());
|
||||
}
|
||||
use flate2::read::ZlibDecoder;
|
||||
ZlibDecoder::new(payload)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("zlib decompress: {e}"))?;
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|e| format!("CTB zlib decompress: {e}"))?;
|
||||
if decoded.len() != text_len {
|
||||
return Err(format!(
|
||||
"CTB content length mismatch: expected {text_len}, got {}",
|
||||
decoded.len()
|
||||
));
|
||||
}
|
||||
} else {
|
||||
use flate2::read::DeflateDecoder;
|
||||
DeflateDecoder::new(payload)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| format!("deflate decompress: {e}"))?;
|
||||
let split_at = data
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let payload = &data[split_at..];
|
||||
if payload.starts_with(&[0x78]) {
|
||||
use flate2::read::ZlibDecoder;
|
||||
ZlibDecoder::new(payload)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|e| format!("legacy CTB zlib decompress: {e}"))?;
|
||||
} else {
|
||||
use flate2::read::DeflateDecoder;
|
||||
DeflateDecoder::new(payload)
|
||||
.read_to_end(&mut decoded)
|
||||
.map_err(|e| format!("legacy CTB deflate decompress: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(text)
|
||||
if decoded.last() == Some(&0) {
|
||||
decoded.pop();
|
||||
}
|
||||
String::from_utf8(decoded).map_err(|e| format!("CTB text is not UTF-8: {e}"))
|
||||
}
|
||||
|
||||
/// Compress plot-style text content as a CTB/STB file.
|
||||
fn compress_ctb(text: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use flate2::{write::ZlibEncoder, Compression};
|
||||
use std::io::Write;
|
||||
let header = b"PIAFILEVERSION_2.0\r\n";
|
||||
let mut compressed: Vec<u8> = Vec::new();
|
||||
{
|
||||
let mut enc = ZlibEncoder::new(&mut compressed, Compression::default());
|
||||
enc.write_all(text).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut out = header.to_vec();
|
||||
const PREFIX: &[u8] = b"PIAFILEVERSION_2.0,CTBVER1,compress\r\npmzlibcodec";
|
||||
let mut body = text.to_vec();
|
||||
body.push(0);
|
||||
let mut enc = ZlibEncoder::new(Vec::new(), Compression::default());
|
||||
enc.write_all(&body).map_err(|e| e.to_string())?;
|
||||
let compressed = enc.finish().map_err(|e| e.to_string())?;
|
||||
let mut out = PREFIX.to_vec();
|
||||
out.extend_from_slice(&adler32(&compressed).to_le_bytes());
|
||||
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&(compressed.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&compressed);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn adler32(bytes: &[u8]) -> u32 {
|
||||
const MOD: u32 = 65_521;
|
||||
let mut a = 1u32;
|
||||
let mut b = 0u32;
|
||||
for byte in bytes {
|
||||
a = (a + u32::from(*byte)) % MOD;
|
||||
b = (b + a) % MOD;
|
||||
}
|
||||
(b << 16) | a
|
||||
}
|
||||
|
||||
fn packed_rgb([r, g, b]: [u8; 3]) -> i32 {
|
||||
u32::from_be_bytes([0xC2, r, g, b]) as i32
|
||||
}
|
||||
|
||||
// ── Text parser ───────────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotStyleTable, String> {
|
||||
if text.lines().any(|line| line.trim() == "begin_plot_style") {
|
||||
return parse_legacy_plot_style_text(text, name, is_stb);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct PendingStyle {
|
||||
index: usize,
|
||||
name: String,
|
||||
entry: PlotStyleEntry,
|
||||
color: Option<i32>,
|
||||
mode_color: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Section {
|
||||
Root,
|
||||
Other,
|
||||
PlotStyles,
|
||||
Lineweights,
|
||||
}
|
||||
|
||||
let mut aci_entries: Vec<PlotStyleEntry> =
|
||||
(0..=255).map(|_| PlotStyleEntry::default()).collect();
|
||||
let mut named_entries: HashMap<String, PlotStyleEntry> = HashMap::default();
|
||||
let mut style_index: usize = 1; // CTB: 1-based ACI index
|
||||
let mut current: Option<PlotStyleEntry> = None;
|
||||
let mut current_name: String = String::new();
|
||||
let mut description = String::new();
|
||||
let mut scale_factor = 1.0f32;
|
||||
let mut apply_factor = false;
|
||||
let mut custom_lineweight_display_units = 0u8;
|
||||
let mut lineweights = Vec::<f32>::new();
|
||||
let mut section = Section::Root;
|
||||
let mut current: Option<PendingStyle> = None;
|
||||
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line == "plot_style{" {
|
||||
section = Section::PlotStyles;
|
||||
continue;
|
||||
}
|
||||
if line == "custom_lineweight_table{" {
|
||||
section = Section::Lineweights;
|
||||
continue;
|
||||
}
|
||||
if line.ends_with('{') {
|
||||
if section == Section::PlotStyles && current.is_none() {
|
||||
if let Ok(index) = line.trim_end_matches('{').trim().parse::<usize>() {
|
||||
let default_name = format!("Color_{}", index + 1);
|
||||
let mut style = PendingStyle {
|
||||
index,
|
||||
name: default_name.clone(),
|
||||
..Default::default()
|
||||
};
|
||||
style.entry.name = default_name.clone();
|
||||
style.entry.localized_name = default_name;
|
||||
current = Some(style);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
section = Section::Other;
|
||||
continue;
|
||||
}
|
||||
if line == "}" {
|
||||
if let Some(mut style) = current.take() {
|
||||
let packed = style.mode_color.or(style.color);
|
||||
style.entry.color = packed.and_then(unpack_plot_color);
|
||||
if is_stb {
|
||||
named_entries.insert(style.name, style.entry);
|
||||
} else if style.index < 255 {
|
||||
aci_entries[style.index + 1] = style.entry;
|
||||
}
|
||||
} else {
|
||||
section = Section::Root;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim();
|
||||
let value = value.trim().trim_start_matches('"');
|
||||
if let Some(style) = current.as_mut() {
|
||||
match key {
|
||||
"name" => {
|
||||
style.name = value.to_string();
|
||||
style.entry.name = value.to_string();
|
||||
}
|
||||
"localized_name" => style.entry.localized_name = value.to_string(),
|
||||
"description" => {
|
||||
style.entry.description = value.to_string();
|
||||
}
|
||||
"screen" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
style.entry.screening = v.min(100);
|
||||
}
|
||||
}
|
||||
"lineweight" => {
|
||||
if let Ok(v) = value.parse::<u8>() {
|
||||
style.entry.lineweight = v;
|
||||
}
|
||||
}
|
||||
"color" => style.color = value.parse::<i32>().ok(),
|
||||
"mode_color" => style.mode_color = value.parse::<i32>().ok(),
|
||||
"color_policy" => style.entry.color_policy = value.parse().unwrap_or(1),
|
||||
"physical_pen_number" => {
|
||||
style.entry.physical_pen_number = value.parse().unwrap_or(0)
|
||||
}
|
||||
"virtual_pen_number" => {
|
||||
style.entry.virtual_pen_number = value.parse().unwrap_or(0)
|
||||
}
|
||||
"linepattern_size" => {
|
||||
style.entry.linepattern_size = value.parse().unwrap_or(0.5)
|
||||
}
|
||||
"linetype" => style.entry.linetype = value.parse().unwrap_or(31),
|
||||
"adaptive_linetype" => {
|
||||
style.entry.adaptive_linetype = value.eq_ignore_ascii_case("TRUE")
|
||||
}
|
||||
"fill_style" => style.entry.fill_style = value.parse().unwrap_or(73),
|
||||
"end_style" => style.entry.end_style = value.parse().unwrap_or(4),
|
||||
"join_style" => style.entry.join_style = value.parse().unwrap_or(5),
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
match section {
|
||||
Section::Root => match key {
|
||||
"description" => description = value.to_string(),
|
||||
"scale_factor" => scale_factor = value.parse().unwrap_or(1.0),
|
||||
"apply_factor" => apply_factor = value.eq_ignore_ascii_case("TRUE"),
|
||||
"custom_lineweight_display_units" => {
|
||||
custom_lineweight_display_units = value.parse().unwrap_or(0)
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Section::Lineweights => {
|
||||
if let (Ok(index), Ok(weight)) = (key.parse::<usize>(), value.parse::<f32>()) {
|
||||
if lineweights.len() <= index {
|
||||
lineweights.resize(index + 1, 0.0);
|
||||
}
|
||||
lineweights[index] = weight;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if lineweights.is_empty() {
|
||||
lineweights = LW_TABLE.to_vec();
|
||||
}
|
||||
|
||||
Ok(PlotStyleTable {
|
||||
name,
|
||||
is_stb,
|
||||
description,
|
||||
scale_factor,
|
||||
apply_factor,
|
||||
custom_lineweight_display_units,
|
||||
lineweights,
|
||||
aci_entries,
|
||||
named_entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn unpack_plot_color(packed: i32) -> Option<[u8; 3]> {
|
||||
if matches!(packed, -1 | -1_006_632_961 | -1_056_964_608) {
|
||||
return None;
|
||||
}
|
||||
let bytes = (packed as u32).to_be_bytes();
|
||||
Some([bytes[1], bytes[2], bytes[3]])
|
||||
}
|
||||
|
||||
fn parse_legacy_plot_style_text(
|
||||
text: &str,
|
||||
name: String,
|
||||
is_stb: bool,
|
||||
) -> Result<PlotStyleTable, String> {
|
||||
let mut table = PlotStyleTable::identity(name);
|
||||
table.is_stb = is_stb;
|
||||
let mut style_index = 1usize;
|
||||
let mut current: Option<PlotStyleEntry> = None;
|
||||
let mut current_name = String::new();
|
||||
for line in text.lines().map(str::trim) {
|
||||
if line == "begin_plot_style" {
|
||||
current = Some(PlotStyleEntry::default());
|
||||
current_name = format!("Color_{}", style_index);
|
||||
current_name = format!("Color_{style_index}");
|
||||
continue;
|
||||
}
|
||||
if line == "end_plot_style" {
|
||||
if let Some(entry) = current.take() {
|
||||
if is_stb {
|
||||
named_entries.insert(current_name.clone(), entry);
|
||||
table.named_entries.insert(current_name.clone(), entry);
|
||||
} else if style_index <= 255 {
|
||||
aci_entries[style_index] = entry;
|
||||
table.aci_entries[style_index] = entry;
|
||||
style_index += 1;
|
||||
}
|
||||
}
|
||||
@@ -236,54 +636,29 @@ fn parse_plot_style_text(text: &str, name: String, is_stb: bool) -> Result<PlotS
|
||||
let Some(entry) = current.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if let Some((key, val)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let val = val.trim();
|
||||
match key {
|
||||
"description" => {
|
||||
if !val.is_empty() {
|
||||
entry.description = val.to_string();
|
||||
current_name = val.to_string();
|
||||
}
|
||||
let Some((key, value)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"description" => {
|
||||
entry.description = value.to_string();
|
||||
if !value.is_empty() {
|
||||
current_name = value.to_string();
|
||||
}
|
||||
"screen" => {
|
||||
if let Ok(v) = val.parse::<u8>() {
|
||||
entry.screening = v;
|
||||
}
|
||||
}
|
||||
"lineweight" => {
|
||||
if let Ok(v) = val.parse::<u8>() {
|
||||
entry.lineweight = v;
|
||||
}
|
||||
}
|
||||
"color1" => {
|
||||
if val.starts_with('#') && val.len() == 7 {
|
||||
// #RRGGBB
|
||||
let r = u8::from_str_radix(&val[1..3], 16).unwrap_or(0);
|
||||
let g = u8::from_str_radix(&val[3..5], 16).unwrap_or(0);
|
||||
let b = u8::from_str_radix(&val[5..7], 16).unwrap_or(0);
|
||||
entry.color = Some([r, g, b]);
|
||||
} else if let Ok(packed) = val.parse::<i32>() {
|
||||
// The file packs RGB as a 0xC0RRGGBB negative integer.
|
||||
// Value 0xC2000000 (-1056964608) = use object color.
|
||||
if packed != -1056964608i32 {
|
||||
let u = packed as u32;
|
||||
let r = ((u >> 16) & 0xFF) as u8;
|
||||
let g = ((u >> 8) & 0xFF) as u8;
|
||||
let b = (u & 0xFF) as u8;
|
||||
entry.color = Some([r, g, b]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
"screen" => entry.screening = value.parse::<u8>().unwrap_or(100).min(100),
|
||||
"lineweight" => entry.lineweight = value.parse().unwrap_or(0),
|
||||
"color1" if value.starts_with('#') && value.len() == 7 => {
|
||||
entry.color = Some([
|
||||
u8::from_str_radix(&value[1..3], 16).unwrap_or(0),
|
||||
u8::from_str_radix(&value[3..5], 16).unwrap_or(0),
|
||||
u8::from_str_radix(&value[5..7], 16).unwrap_or(0),
|
||||
]);
|
||||
}
|
||||
"color1" => entry.color = value.parse::<i32>().ok().and_then(unpack_plot_color),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PlotStyleTable {
|
||||
name,
|
||||
is_stb,
|
||||
aci_entries,
|
||||
named_entries,
|
||||
})
|
||||
Ok(table)
|
||||
}
|
||||
+20
-29
@@ -15,7 +15,8 @@ use crate::scene::WireModel;
|
||||
|
||||
/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp`
|
||||
/// flags / `-o` options. On Windows the generated PDF already carries render
|
||||
/// options, while copies and driver quality are not exposed by the shell verb.
|
||||
/// options. Windows queues repeated jobs when more than one copy is requested;
|
||||
/// driver quality remains managed by the selected printer.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
pub struct PrintOptions {
|
||||
@@ -23,12 +24,8 @@ pub struct PrintOptions {
|
||||
pub printer: Option<String>,
|
||||
/// Number of copies (treated as at least 1).
|
||||
pub copies: u32,
|
||||
/// Force grayscale output.
|
||||
pub mono: bool,
|
||||
/// Print quality label selected in the plot dialog.
|
||||
pub quality: Option<String>,
|
||||
/// Rasterisation resolution in DPI.
|
||||
pub dpi: Option<u32>,
|
||||
/// Controls applied while building the intermediate PDF.
|
||||
pub render: crate::io::pdf_export::PdfPlotOptions,
|
||||
}
|
||||
@@ -215,8 +212,7 @@ fn dispatch_to_printer_opts(
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Target a named printer via the "printto" verb; fall back to the
|
||||
// default-printer "print" verb. Copies / quality / colour aren't
|
||||
// expressible through a shell verb, so they are ignored here.
|
||||
// default-printer "print" verb.
|
||||
use std::ffi::OsStr;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
let wide = |s: &str| -> Vec<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
|
||||
@@ -226,21 +222,22 @@ fn dispatch_to_printer_opts(
|
||||
_ => (wide("print"), None, "default printer".to_string()),
|
||||
};
|
||||
let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null());
|
||||
let result = unsafe {
|
||||
windows_sys::Win32::UI::Shell::ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
verb.as_ptr(),
|
||||
path_wide.as_ptr(),
|
||||
params_ptr,
|
||||
std::ptr::null(),
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
) as usize
|
||||
};
|
||||
if result > 32 {
|
||||
Ok(label)
|
||||
} else {
|
||||
Err(format!("ShellExecute failed (code {result})"))
|
||||
for _ in 0..opts.copies.max(1) {
|
||||
let result = unsafe {
|
||||
windows_sys::Win32::UI::Shell::ShellExecuteW(
|
||||
std::ptr::null_mut(),
|
||||
verb.as_ptr(),
|
||||
path_wide.as_ptr(),
|
||||
params_ptr,
|
||||
std::ptr::null(),
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE,
|
||||
) as usize
|
||||
};
|
||||
if result <= 32 {
|
||||
return Err(format!("ShellExecute failed (code {result})"));
|
||||
}
|
||||
}
|
||||
Ok(label)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
@@ -256,17 +253,11 @@ fn dispatch_to_printer_opts(
|
||||
if copies > 1 {
|
||||
cmd.arg("-n").arg(copies.to_string());
|
||||
}
|
||||
if opts.mono {
|
||||
cmd.arg("-o").arg("ColorModel=Gray");
|
||||
}
|
||||
if let Some(dpi) = opts.dpi {
|
||||
cmd.arg("-o").arg(format!("Resolution={dpi}dpi"));
|
||||
}
|
||||
if let Some(q) = opts.quality.as_deref() {
|
||||
// CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best.
|
||||
let pq = match q {
|
||||
"Draft" => "3",
|
||||
"Presentation" | "Maximum" => "5",
|
||||
"Low" => "3",
|
||||
"High" => "5",
|
||||
_ => "4",
|
||||
};
|
||||
cmd.arg("-o").arg(format!("print-quality={pq}"));
|
||||
|
||||
Reference in new issue
Block a user