Compare commits
20
Commits
c71b7d4e6e
...
238cc435bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
238cc435bf | ||
|
|
c1298415fc | ||
|
|
04772ff49e | ||
|
|
7b3cef6e48 | ||
|
|
a67a0fa2fb | ||
|
|
c795669b01 | ||
|
|
f87eac3227 | ||
|
|
3bd7152834 | ||
|
|
c920a8f861 | ||
|
|
36534a1e96 | ||
|
|
2fb8a7f7b9 | ||
|
|
217118aa24 | ||
|
|
303f22998c | ||
|
|
29e45e93ea | ||
|
|
122def5e63 | ||
|
|
06c783fde1 | ||
|
|
03c238bd43 | ||
|
|
4cb1284281 | ||
|
|
1f667f9851 | ||
|
|
efc7dc8669 |
@@ -13,15 +13,20 @@ jobs:
|
||||
matrix:
|
||||
platform:
|
||||
- name: Intel
|
||||
os: macos-13
|
||||
os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
binary_dir: macos-x86_64
|
||||
arch: x86_64
|
||||
- name: Apple Silicon
|
||||
os: macos-14
|
||||
os: macos-15
|
||||
target: aarch64-apple-darwin
|
||||
binary_dir: macos-aarch64
|
||||
arch: aarm64
|
||||
arch: arm64
|
||||
- name: Universal (Intel + Apple Silicon)
|
||||
os: macos-15
|
||||
target: universal-apple-darwin
|
||||
binary_dir: macos-universal
|
||||
arch: universal
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -37,7 +42,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform.target }}
|
||||
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -68,7 +73,7 @@ jobs:
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Tauri App
|
||||
run: npm run tauri build
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
shell: bash
|
||||
@@ -76,18 +81,18 @@ jobs:
|
||||
mkdir -p release-assets
|
||||
|
||||
# Stage DMG package
|
||||
for dmg in src-tauri/target/release/bundle/dmg/*.dmg; do
|
||||
if [ -f "$dmg" ]; then
|
||||
cp "$dmg" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
done
|
||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
|
||||
# Stage ZIP archive of .app bundle
|
||||
for app in src-tauri/target/release/bundle/macos/*.app; do
|
||||
if [ -d "$app" ]; then
|
||||
(cd src-tauri/target/release/bundle/macos && zip -r "../../../../../release-assets/${PREFIX}.zip" "$(basename "$app")")
|
||||
fi
|
||||
done
|
||||
APP_DIR=$(find src-tauri/target -type d -name "*.app" | head -n 1)
|
||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
||||
APP_PARENT=$(dirname "$APP_DIR")
|
||||
APP_NAME=$(basename "$APP_DIR")
|
||||
(cd "$APP_PARENT" && zip -r "${PWD}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -24,7 +24,12 @@ jobs:
|
||||
os: macos-15
|
||||
target: aarch64-apple-darwin
|
||||
binary_dir: macos-aarch64
|
||||
arch: aarm64
|
||||
arch: arm64
|
||||
- name: Universal (Intel + Apple Silicon)
|
||||
os: macos-15
|
||||
target: universal-apple-darwin
|
||||
binary_dir: macos-universal
|
||||
arch: universal
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -40,7 +45,7 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.platform.target }}
|
||||
targets: ${{ matrix.platform.target == 'universal-apple-darwin' && 'x86_64-apple-darwin, aarch64-apple-darwin' || matrix.platform.target }}
|
||||
|
||||
- name: Rust Cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
@@ -70,7 +75,7 @@ jobs:
|
||||
echo "PREFIX=ICCery_${TAG}-${SHORT_SHA}-macos-${{ matrix.platform.arch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Tauri App
|
||||
run: npm run tauri build
|
||||
run: npm run tauri build -- --target ${{ matrix.platform.target }}
|
||||
|
||||
- name: Prepare Release Assets
|
||||
shell: bash
|
||||
@@ -78,18 +83,18 @@ jobs:
|
||||
mkdir -p release-assets
|
||||
|
||||
# Stage DMG package
|
||||
for dmg in src-tauri/target/release/bundle/dmg/*.dmg; do
|
||||
if [ -f "$dmg" ]; then
|
||||
cp "$dmg" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
done
|
||||
DMG_FILE=$(find src-tauri/target -type f -name "*.dmg" | head -n 1)
|
||||
if [ -n "$DMG_FILE" ] && [ -f "$DMG_FILE" ]; then
|
||||
cp "$DMG_FILE" "release-assets/${PREFIX}.dmg"
|
||||
fi
|
||||
|
||||
# Stage ZIP archive of .app bundle
|
||||
for app in src-tauri/target/release/bundle/macos/*.app; do
|
||||
if [ -d "$app" ]; then
|
||||
(cd src-tauri/target/release/bundle/macos && zip -r "../../../../../release-assets/${PREFIX}.zip" "$(basename "$app")")
|
||||
fi
|
||||
done
|
||||
APP_DIR=$(find src-tauri/target -type d -name "*.app" | head -n 1)
|
||||
if [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then
|
||||
APP_PARENT=$(dirname "$APP_DIR")
|
||||
APP_NAME=$(basename "$APP_DIR")
|
||||
(cd "$APP_PARENT" && zip -r "${PWD}/release-assets/${PREFIX}.zip" "$APP_NAME")
|
||||
fi
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -27,4 +27,6 @@ src-tauri/argyll/linux-x86_64/
|
||||
src-tauri/argyll/windows-x86_64/
|
||||
src-tauri/argyll/macos-x86_64/
|
||||
src-tauri/argyll/macos-aarch64/
|
||||
src-tauri/argyll/macos-universal/
|
||||
src-tauri/argyll/usb/
|
||||
|
||||
|
||||
+3
-1
@@ -71,11 +71,13 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- [x] **v0.3.4 & v0.3.5 (#119, #134)**: TIFF preview metadata extraction, printtarg custom patch labels, and robust `instlist` JSON parsing.
|
||||
- [x] **v0.3.6 (#137)**: Interactive `chartread` support for multi-key prompts (accept/override key combos).
|
||||
|
||||
### Milestone 10 — Production Ready Features (`v0.5.0` & `v0.5.1`)
|
||||
### Milestone 10 — Production Ready Features (`v0.5.0` – `v0.5.5`)
|
||||
- [x] **Structured Logging (#139)**: Integrated `tauri-plugin-log` with rotating logs in native OS app log directory and ArgyllCMS stdout/stderr stream capture.
|
||||
- [x] **Stage 3 Direct Resume (#140)**: Open existing `.ti2` target file in Stage 1 to parse header metadata and jump directly to Stage 3 (Measurement).
|
||||
- [x] **Stage 1 Additional Customisation & Tooltips (#141)**: Exposure of 11 advanced `targen` tuning parameters (`-g`, `-s`, `-n`, `-c`, `-N`, `-G`, `-A`, algorithm selection, `-l`, `-V`, `-p`) in a collapsible section with contextual guidance tooltips and preset support.
|
||||
- [x] **Subprocess Lifecycle & Clean Exit (#147, #149)**: Tauri application and window close hooks invoking `ProcessManager::kill_all` to prevent orphaned hardware-locking processes.
|
||||
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
|
||||
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"version": "0.3.3",
|
||||
"version": "0.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "iccery",
|
||||
"version": "0.3.3",
|
||||
"version": "0.5.5",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||
|
||||
@@ -43,6 +43,12 @@ const PLATFORMS = {
|
||||
destDir: path.join(TAURI_ARGYLL_DIR, 'macos-aarch64'),
|
||||
isWindows: false,
|
||||
},
|
||||
'macos-universal': {
|
||||
assetSuffix: '_macOS_universal_bin.tgz',
|
||||
markerBinary: 'instlist',
|
||||
destDir: path.join(TAURI_ARGYLL_DIR, 'macos-universal'),
|
||||
isWindows: false,
|
||||
},
|
||||
};
|
||||
|
||||
function parseArgs() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.5.4"
|
||||
version = "0.5.5"
|
||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||
authors = ["Gordon"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -65,8 +65,20 @@ pub async fn resolve_binary(app: AppHandle, binary_name: String) -> Result<Strin
|
||||
let platform = match (std::env::consts::OS, std::env::consts::ARCH) {
|
||||
("linux", "x86_64") => "linux-x86_64",
|
||||
("windows", "x86_64") => "windows-x86_64",
|
||||
("macos", "aarch64") => "macos-aarch64",
|
||||
("macos", "x86_64") => "macos-x86_64",
|
||||
("macos", "aarch64") => {
|
||||
if app.path().resolve("argyll/macos-universal/instlist", tauri::path::BaseDirectory::Resource).map(|p| p.exists()).unwrap_or(false) {
|
||||
"macos-universal"
|
||||
} else {
|
||||
"macos-aarch64"
|
||||
}
|
||||
},
|
||||
("macos", "x86_64") => {
|
||||
if app.path().resolve("argyll/macos-universal/instlist", tauri::path::BaseDirectory::Resource).map(|p| p.exists()).unwrap_or(false) {
|
||||
"macos-universal"
|
||||
} else {
|
||||
"macos-x86_64"
|
||||
}
|
||||
},
|
||||
_ => "linux-x86_64",
|
||||
};
|
||||
|
||||
@@ -557,6 +569,10 @@ fn default_dpi() -> u32 {
|
||||
300
|
||||
}
|
||||
|
||||
fn default_random_seed() -> Option<u32> {
|
||||
Some(1)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct PrinttargConfig {
|
||||
pub instrument: String, // One of: "i1", "p3", "CM", "SS", "20", "22", "41", "51"
|
||||
@@ -565,6 +581,10 @@ pub struct PrinttargConfig {
|
||||
#[serde(default = "default_dpi")]
|
||||
pub dpi: u32, // TIFF resolution, defaults to 300 DPI
|
||||
pub custom_label: Option<String>, // Custom chart label / description metadata string (-d)
|
||||
#[serde(default = "default_random_seed")]
|
||||
pub random_seed: Option<u32>, // Random seed (-R <seed>), defaults to Some(1) for deterministic target generation
|
||||
#[serde(default)]
|
||||
pub no_randomize: bool, // If true, pass -r (raster layout / no randomization)
|
||||
pub basename: String, // Must match the .ti1 basename from Stage 1
|
||||
pub cwd: String, // Working directory where the .ti1 file resides
|
||||
}
|
||||
@@ -698,6 +718,13 @@ pub fn build_printtarg_args(config: &PrinttargConfig) -> Vec<String> {
|
||||
config.page_size.clone(),
|
||||
];
|
||||
|
||||
if config.no_randomize {
|
||||
args.push("-r".to_string());
|
||||
} else if let Some(seed) = config.random_seed {
|
||||
args.push("-R".to_string());
|
||||
args.push(seed.to_string());
|
||||
}
|
||||
|
||||
if let Some(ref label) = config.custom_label {
|
||||
args.push("-d".to_string());
|
||||
args.push(label.clone());
|
||||
@@ -1289,11 +1316,13 @@ mod tests {
|
||||
bit_depth: 8,
|
||||
dpi: 100,
|
||||
custom_label: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-t", "100", "my_profile"]);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "1", "-t", "100", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1304,11 +1333,13 @@ mod tests {
|
||||
bit_depth: 16,
|
||||
dpi: 300,
|
||||
custom_label: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "cmyk_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-T", "300", "cmyk_profile"]);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "CM", "-p", "Letter", "-R", "1", "-T", "300", "cmyk_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1319,11 +1350,13 @@ mod tests {
|
||||
bit_depth: 8,
|
||||
dpi: 150,
|
||||
custom_label: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "custom_target".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-t", "150", "custom_target"]);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "SS", "-p", "200x400", "-R", "1", "-t", "150", "custom_target"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1334,6 +1367,8 @@ mod tests {
|
||||
bit_depth: 8,
|
||||
dpi: 300,
|
||||
custom_label: Some("ICCery - Pro900 - Luster - 29/08/2026 12:00".to_string()),
|
||||
random_seed: Some(1),
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
@@ -1347,6 +1382,8 @@ mod tests {
|
||||
"i1",
|
||||
"-p",
|
||||
"A4",
|
||||
"-R",
|
||||
"1",
|
||||
"-d",
|
||||
"ICCery - Pro900 - Luster - 29/08/2026 12:00",
|
||||
"-t",
|
||||
@@ -1356,6 +1393,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_with_custom_seed() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "i1".to_string(),
|
||||
page_size: "A4".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 300,
|
||||
custom_label: None,
|
||||
random_seed: Some(42),
|
||||
no_randomize: false,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-R", "42", "-t", "300", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_printtarg_args_with_raster_order() {
|
||||
let config = PrinttargConfig {
|
||||
instrument: "i1".to_string(),
|
||||
page_size: "A4".to_string(),
|
||||
bit_depth: 8,
|
||||
dpi: 300,
|
||||
custom_label: None,
|
||||
random_seed: Some(1), // Should be superseded by no_randomize
|
||||
no_randomize: true,
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/tmp".to_string(),
|
||||
};
|
||||
let args = build_printtarg_args(&config);
|
||||
assert_eq!(args, vec!["-v", "-u", "-i", "i1", "-p", "A4", "-r", "-t", "300", "my_profile"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_chartread_args_auto() {
|
||||
let config = ChartreadConfig {
|
||||
|
||||
@@ -42,6 +42,12 @@ pub struct ProfilingPreset {
|
||||
pub dark_emphasis: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub device_power: Option<f64>,
|
||||
|
||||
// Stage 2 fields
|
||||
#[serde(default)]
|
||||
pub random_seed: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub no_randomize: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
|
||||
@@ -322,6 +328,8 @@ mod tests {
|
||||
total_ink_limit: Some(320),
|
||||
dark_emphasis: Some(1.5),
|
||||
device_power: Some(1.2),
|
||||
random_seed: Some(42),
|
||||
no_randomize: Some(false),
|
||||
};
|
||||
|
||||
let json = export_preset_json(preset.clone()).expect("Export failed");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ICCery",
|
||||
"version": "0.5.4",
|
||||
"version": "0.5.5",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
||||
+149
-114
@@ -144,142 +144,156 @@
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Advanced Section -->
|
||||
<details id="targenAdvancedDetails" class="advanced-section-details" style="margin-top: 10px; border: 1px solid var(--border-color, #333); border-radius: 8px; padding: 10px 14px; background: rgba(0,0,0,0.15);">
|
||||
<summary style="font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: space-between; user-select: none;">
|
||||
<details id="targenAdvancedDetails" class="advanced-section-details">
|
||||
<summary>
|
||||
<span>⚙️ Advanced Target Generation Options</span>
|
||||
<span class="summary-badge" style="font-size: 0.75rem; opacity: 0.6;">Click to expand</span>
|
||||
</summary>
|
||||
|
||||
<div class="advanced-content" style="margin-top: 14px; display: flex; flex-direction: column; gap: 12px;">
|
||||
<!-- Grey & Single Channel Steps -->
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenGreySteps" class="sub-label">Grey / Combined Axis Steps (-g)</label>
|
||||
<input type="number" id="targenGreySteps" min="0" max="256" placeholder="0">
|
||||
<div class="advanced-content">
|
||||
<!-- Group 1: Neutral Axis & Preconditioning -->
|
||||
<div class="advanced-group">
|
||||
<div class="advanced-group-title">Neutral Axis & Preconditioning</div>
|
||||
|
||||
<div class="form-group has-tooltip" style="margin-bottom: 0;">
|
||||
<label for="targenPrecondProfile" class="sub-label">Preconditioning Profile (-c)</label>
|
||||
<div class="input-row has-btn">
|
||||
<input type="text" id="targenPrecondProfile" placeholder="Optional previous profile (.icc / .icm)">
|
||||
<button type="button" id="btnBrowsePrecondProfile" class="secondary">Browse...</button>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Number of evenly-spaced steps along the combined grey axis (equal R=G=B or C=M=Y).<br>
|
||||
These patches improve neutral-axis rendering and are useful when device will later be used as source space. 0 = none.
|
||||
Optional previous ICC/ICM (or MPP) profile of this or a similar device.<br>
|
||||
Used by targen to estimate perceptual distances, colourspace curvature and the true neutral axis. Enables adaptive OFPS and Neutral Axis Steps.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenSingleChannelSteps" class="sub-label">Single-Channel Steps (-s)</label>
|
||||
<input type="number" id="targenSingleChannelSteps" min="0" max="256" placeholder="0">
|
||||
<div class="tooltip-text">
|
||||
Number of steps for each individual colorant wedge (R, G, B or C, M, Y, K).<br>
|
||||
Useful for diagnosing per-channel non-linearity and source-space characterisation. 0 = none.
|
||||
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenNeutralSteps" class="sub-label">Neutral Axis Steps (-n)</label>
|
||||
<input type="number" id="targenNeutralSteps" min="0" max="256" placeholder="0">
|
||||
<div class="tooltip-text">
|
||||
Number of patches placed along the true neutral axis as estimated by preconditioning profile.<br>
|
||||
Requires a Preconditioning Profile. Improves neutral rendering of final profile.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenNeutralConcentration" class="sub-label">Neutral Axis Concentration (-N)</label>
|
||||
<div class="slider-input-container">
|
||||
<input type="range" id="targenNeutralConcentration" min="0.0" max="1.0" step="0.05" value="0.5">
|
||||
<span id="targenNeutralConcVal" class="slider-val-badge">0.50</span>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
How strongly the patch distribution should favour the neutral axis (0 = none, 1 = maximum).<br>
|
||||
Default 0.5. Most effective with preconditioning profile and high OFPS Adaptation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preconditioning Profile & Neutral Axis Steps -->
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenPrecondProfile" class="sub-label">Preconditioning Profile (-c)</label>
|
||||
<div class="input-row has-btn">
|
||||
<input type="text" id="targenPrecondProfile" placeholder="Optional previous profile (.icc / .icm)">
|
||||
<button type="button" id="btnBrowsePrecondProfile" class="secondary">Browse...</button>
|
||||
<!-- Group 2: Patch Distribution & Sampling -->
|
||||
<div class="advanced-group">
|
||||
<div class="advanced-group-title">Patch Distribution & Sampling</div>
|
||||
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenGreySteps" class="sub-label">Grey / Combined Axis Steps (-g)</label>
|
||||
<input type="number" id="targenGreySteps" min="0" max="256" placeholder="0">
|
||||
<div class="tooltip-text">
|
||||
Number of evenly-spaced steps along the combined grey axis (equal R=G=B or C=M=Y).<br>
|
||||
These patches improve neutral-axis rendering and are useful when device will later be used as source space. 0 = none.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenSingleChannelSteps" class="sub-label">Single-Channel Steps (-s)</label>
|
||||
<input type="number" id="targenSingleChannelSteps" min="0" max="256" placeholder="0">
|
||||
<div class="tooltip-text">
|
||||
Number of steps for each individual colorant wedge (R, G, B or C, M, Y, K).<br>
|
||||
Useful for diagnosing per-channel non-linearity and source-space characterisation. 0 = none.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Optional previous ICC/ICM (or MPP) profile of this or a similar device.<br>
|
||||
Used by targen to estimate perceptual distances, colourspace curvature and the true neutral axis. Enables adaptive OFPS and Neutral Axis Steps.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenNeutralSteps" class="sub-label">Neutral Axis Steps (-n)</label>
|
||||
<input type="number" id="targenNeutralSteps" min="0" max="256" placeholder="0">
|
||||
<div class="tooltip-text">
|
||||
Number of patches placed along the true neutral axis as estimated by preconditioning profile.<br>
|
||||
Requires a Preconditioning Profile. Improves neutral rendering of final profile.
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenAdaptation" class="sub-label">OFPS Adaptation (-A)</label>
|
||||
<div class="slider-input-container">
|
||||
<input type="range" id="targenAdaptation" min="0.0" max="1.0" step="0.05" value="0.1">
|
||||
<span id="targenAdaptationVal" class="slider-val-badge">0.10</span>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Degree to which OFPS adapts to device behaviour from preconditioning profile (0 = ignore, 1 = fully adapt).<br>
|
||||
Default 0.1; automatically rises to 1.0 when preconditioning profile is supplied.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenDarkEmphasis" class="sub-label">Dark Region Emphasis (-V)</label>
|
||||
<div class="slider-input-container">
|
||||
<input type="range" id="targenDarkEmphasis" min="1.0" max="4.0" step="0.1" value="1.0">
|
||||
<span id="targenDarkEmphasisVal" class="slider-val-badge">1.00</span>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Concentrates more patches toward dark tones (1.0 = none, up to 4.0 = strong).<br>
|
||||
Useful for devices with non-linearity or important shadow detail. Default 1.0.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenNeutralConcentration" class="sub-label">Neutral Axis Concentration (-N)</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" id="targenNeutralConcentration" min="0.0" max="1.0" step="0.05" value="0.5" style="flex: 1;">
|
||||
<span id="targenNeutralConcVal" style="font-family: monospace; font-size: 0.8rem; min-width: 35px;">0.50</span>
|
||||
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenDevicePower" class="sub-label">Device Value Power (-p)</label>
|
||||
<input type="number" id="targenDevicePower" min="0.1" max="4.0" step="0.1" placeholder="Linear (1.0)">
|
||||
<div class="tooltip-text">
|
||||
Optional power-like remapping applied to device values after generation.<br>
|
||||
Values > 1 tighten spacing near 0; values < 1 tighten spacing near 1. Leave empty for linear spacing.
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
How strongly the patch distribution should favour the neutral axis (0 = none, 1 = maximum).<br>
|
||||
Default 0.5. Most effective with preconditioning profile and high OFPS Adaptation.
|
||||
<div class="form-group has-tooltip" id="targenInkLimitGroup">
|
||||
<label for="targenInkLimit" class="sub-label">Total Ink Limit / TAC (-l %)</label>
|
||||
<input type="number" id="targenInkLimit" min="0" max="400" placeholder="e.g. 320" disabled>
|
||||
<div class="tooltip-text">
|
||||
Maximum total ink coverage (Total Area Coverage) in percent.<br>
|
||||
Essential for CMYK to avoid over-inking. Set at least 10% higher than final profile TAC. Disabled in RGB mode.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quality & Algorithm -->
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenAlgorithm" class="sub-label">Full-spread Algorithm</label>
|
||||
<select id="targenAlgorithm">
|
||||
<option value="ofps" selected>OFPS (Optimised Farthest Point Sampling - Default)</option>
|
||||
<option value="t">Incremental Far Point (-t)</option>
|
||||
<option value="r">Device Random (-r)</option>
|
||||
<option value="R">Perceptual Random (-R)</option>
|
||||
<option value="q">Device Quasi-Random (-q)</option>
|
||||
<option value="Q">Perceptual Quasi-Random (-Q)</option>
|
||||
<option value="i">Device BCC (-i)</option>
|
||||
<option value="I">Perceptual BCC (-I)</option>
|
||||
</select>
|
||||
<div class="tooltip-text">
|
||||
Algorithm used to place remaining “full-spread” patches.<br>
|
||||
• OFPS (default) – best evenness in most cases.<br>
|
||||
• Incremental Far Point – used automatically for >4 channels.<br>
|
||||
• Device/Perceptual random or BCC – alternative space-filling methods.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip" style="flex: 1; display: flex; flex-direction: column; justify-content: flex-end;">
|
||||
<label style="cursor: pointer; display: flex; align-items: center; gap: 8px; margin-bottom: 6px;">
|
||||
<input type="checkbox" id="targenHighQuality">
|
||||
<span style="font-size: 0.85rem;">High Quality OFPS (-G)</span>
|
||||
</label>
|
||||
<div class="tooltip-text">
|
||||
When checked, targen runs the OFPS algorithm in “good” mode (more iterations).<br>
|
||||
Produces a more evenly distributed set of patches at cost of longer generation time. Recommended for final production charts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Group 3: Algorithm & Quality -->
|
||||
<div class="advanced-group">
|
||||
<div class="advanced-group-title">Algorithm & Quality</div>
|
||||
|
||||
<!-- Adaptation & Ink Limit & Tone Remapping -->
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenAdaptation" class="sub-label">OFPS Adaptation (-A)</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" id="targenAdaptation" min="0.0" max="1.0" step="0.05" value="0.1" style="flex: 1;">
|
||||
<span id="targenAdaptationVal" style="font-family: monospace; font-size: 0.8rem; min-width: 35px;">0.10</span>
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="targenAlgorithm" class="sub-label">Full-spread Algorithm</label>
|
||||
<select id="targenAlgorithm">
|
||||
<option value="ofps" selected>OFPS (Optimised Farthest Point Sampling - Default)</option>
|
||||
<option value="t">Incremental Far Point (-t)</option>
|
||||
<option value="r">Device Random (-r)</option>
|
||||
<option value="R">Perceptual Random (-R)</option>
|
||||
<option value="q">Device Quasi-Random (-q)</option>
|
||||
<option value="Q">Perceptual Quasi-Random (-Q)</option>
|
||||
<option value="i">Device BCC (-i)</option>
|
||||
<option value="I">Perceptual BCC (-I)</option>
|
||||
</select>
|
||||
<div class="tooltip-text">
|
||||
Algorithm used to place remaining “full-spread” patches.<br>
|
||||
• OFPS (default) – best evenness in most cases.<br>
|
||||
• Incremental Far Point – used automatically for >4 channels.<br>
|
||||
• Device/Perceptual random or BCC – alternative space-filling methods.
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Degree to which OFPS adapts to device behaviour from preconditioning profile (0 = ignore, 1 = fully adapt).<br>
|
||||
Default 0.1; automatically rises to 1.0 when preconditioning profile is supplied.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip" id="targenInkLimitGroup" style="flex: 1;">
|
||||
<label for="targenInkLimit" class="sub-label">Total Ink Limit / TAC (-l %)</label>
|
||||
<input type="number" id="targenInkLimit" min="0" max="400" placeholder="e.g. 320" disabled>
|
||||
<div class="tooltip-text">
|
||||
Maximum total ink coverage (Total Area Coverage) in percent.<br>
|
||||
Essential for CMYK to avoid over-inking. Set at least 10% higher than final profile TAC. Disabled in RGB mode.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-row">
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenDarkEmphasis" class="sub-label">Dark Region Emphasis (-V)</label>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<input type="range" id="targenDarkEmphasis" min="1.0" max="4.0" step="0.1" value="1.0" style="flex: 1;">
|
||||
<span id="targenDarkEmphasisVal" style="font-family: monospace; font-size: 0.8rem; min-width: 35px;">1.00</span>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Concentrates more patches toward dark tones (1.0 = none, up to 4.0 = strong).<br>
|
||||
Useful for devices with non-linearity or important shadow detail. Default 1.0.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group has-tooltip" style="flex: 1;">
|
||||
<label for="targenDevicePower" class="sub-label">Device Value Power (-p)</label>
|
||||
<input type="number" id="targenDevicePower" min="0.1" max="4.0" step="0.1" placeholder="Linear (1.0)">
|
||||
<div class="tooltip-text">
|
||||
Optional power-like remapping applied to device values after generation.<br>
|
||||
Values > 1 tighten spacing near 0; values < 1 tighten spacing near 1. Leave empty for linear spacing.
|
||||
<div class="form-group has-tooltip">
|
||||
<label class="sub-label">Quality Optimization</label>
|
||||
<div class="checkbox-control-container">
|
||||
<label>
|
||||
<input type="checkbox" id="targenHighQuality">
|
||||
<span style="font-size: 0.85rem;">High Quality OFPS (-G)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
When checked, targen runs the OFPS algorithm in “good” mode (more iterations).<br>
|
||||
Produces a more evenly distributed set of patches at cost of longer generation time. Recommended for final production charts.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,6 +377,27 @@
|
||||
<input type="number" id="tiffDpi" min="72" max="600" value="300" placeholder="300">
|
||||
</div>
|
||||
|
||||
<div class="form-group has-tooltip">
|
||||
<label for="printtargLayoutOrder">Patch Layout Order</label>
|
||||
<div class="input-row" style="align-items: center; gap: 10px;">
|
||||
<select id="printtargLayoutOrder" style="flex: 2;">
|
||||
<option value="deterministic" selected>Deterministic Random (Default, Seed = 1)</option>
|
||||
<option value="custom_seed">Custom Random Seed (-R)</option>
|
||||
<option value="raster">Raster Order (Sequential, -r)</option>
|
||||
</select>
|
||||
<div id="printtargCustomSeedGroup" class="hidden" style="flex: 1; display: flex; align-items: center; gap: 6px;">
|
||||
<label for="printtargCustomSeed" class="sub-label" style="margin: 0; white-space: nowrap;">Seed:</label>
|
||||
<input type="number" id="printtargCustomSeed" min="1" max="2147483647" value="1" placeholder="1" style="flex: 1;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="tooltip-text">
|
||||
Controls patch distribution across target pages.<br>
|
||||
• <strong>Deterministic (Seed = 1)</strong>: Guarantees identical .ti2 and TIFF target generation each run for the same .ti1.<br>
|
||||
• <strong>Custom Seed</strong>: Reproducible pseudo-random distribution using an explicit integer seed (-R <seed>).<br>
|
||||
• <strong>Raster Order</strong>: Unrandomized sequential order (-r) across rows and columns.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target Label & Metadata (printtarg -d) -->
|
||||
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
|
||||
@@ -221,6 +221,23 @@ export async function initPresets() {
|
||||
tiffDpi.value = preset.dpi;
|
||||
}
|
||||
|
||||
const printtargLayoutOrder = document.getElementById("printtargLayoutOrder");
|
||||
const printtargCustomSeedGroup = document.getElementById("printtargCustomSeedGroup");
|
||||
const printtargCustomSeed = document.getElementById("printtargCustomSeed");
|
||||
if (printtargLayoutOrder) {
|
||||
if (preset.no_randomize) {
|
||||
printtargLayoutOrder.value = "raster";
|
||||
if (printtargCustomSeedGroup) printtargCustomSeedGroup.classList.add("hidden");
|
||||
} else if (preset.random_seed && preset.random_seed !== 1) {
|
||||
printtargLayoutOrder.value = "custom_seed";
|
||||
if (printtargCustomSeed) printtargCustomSeed.value = preset.random_seed;
|
||||
if (printtargCustomSeedGroup) printtargCustomSeedGroup.classList.remove("hidden");
|
||||
} else {
|
||||
printtargLayoutOrder.value = "deterministic";
|
||||
if (printtargCustomSeedGroup) printtargCustomSeedGroup.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4 controls
|
||||
const colprofQuality = document.getElementById("colprofQuality");
|
||||
if (colprofQuality && preset.colprof_quality) colprofQuality.value = preset.colprof_quality;
|
||||
@@ -296,6 +313,22 @@ export async function initPresets() {
|
||||
const tiffDpi = document.getElementById("tiffDpi");
|
||||
const dpi = tiffDpi && tiffDpi.value ? parseInt(tiffDpi.value, 10) || 300 : 300;
|
||||
|
||||
const printtargLayoutOrder = document.getElementById("printtargLayoutOrder");
|
||||
const printtargCustomSeed = document.getElementById("printtargCustomSeed");
|
||||
let random_seed = 1;
|
||||
let no_randomize = false;
|
||||
if (printtargLayoutOrder) {
|
||||
if (printtargLayoutOrder.value === "raster") {
|
||||
no_randomize = true;
|
||||
random_seed = null;
|
||||
} else if (printtargLayoutOrder.value === "custom_seed") {
|
||||
const s = printtargCustomSeed ? parseInt(printtargCustomSeed.value, 10) : 1;
|
||||
random_seed = isNaN(s) || s < 1 ? 1 : s;
|
||||
} else {
|
||||
random_seed = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const colprofQuality = document.getElementById("colprofQuality");
|
||||
const colprof_quality = colprofQuality ? colprofQuality.value : "m";
|
||||
|
||||
@@ -325,6 +358,8 @@ export async function initPresets() {
|
||||
page_size,
|
||||
bit_depth,
|
||||
dpi,
|
||||
random_seed,
|
||||
no_randomize,
|
||||
colprof_algorithm,
|
||||
colprof_quality,
|
||||
colprof_intent: null,
|
||||
|
||||
@@ -33,6 +33,9 @@ export function initPrinttarg() {
|
||||
const customPageH = document.getElementById("customPageH");
|
||||
const bitDepthRadios = document.querySelectorAll('input[name="bitDepth"]');
|
||||
const tiffDpi = document.getElementById("tiffDpi");
|
||||
const printtargLayoutOrder = document.getElementById("printtargLayoutOrder");
|
||||
const printtargCustomSeedGroup = document.getElementById("printtargCustomSeedGroup");
|
||||
const printtargCustomSeed = document.getElementById("printtargCustomSeed");
|
||||
const btnCreateLayout = document.getElementById("btnCreateLayout");
|
||||
const logContainer = document.getElementById("printtargLogContainer");
|
||||
const logPre = document.getElementById("printtargLog");
|
||||
@@ -40,6 +43,16 @@ export function initPrinttarg() {
|
||||
const galleryInfo = document.getElementById("galleryInfo");
|
||||
const galleryGrid = document.getElementById("galleryGrid");
|
||||
|
||||
if (printtargLayoutOrder && printtargCustomSeedGroup) {
|
||||
printtargLayoutOrder.addEventListener("change", () => {
|
||||
if (printtargLayoutOrder.value === "custom_seed") {
|
||||
printtargCustomSeedGroup.classList.remove("hidden");
|
||||
} else {
|
||||
printtargCustomSeedGroup.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Target Label & Metadata elements (printtarg -d)
|
||||
const targetMetadataPrinter = document.getElementById("targetMetadataPrinter");
|
||||
const targetMetadataInkSet = document.getElementById("targetMetadataInkSet");
|
||||
@@ -402,12 +415,30 @@ export function initPrinttarg() {
|
||||
customLabel = targetLabelPreview.value.trim();
|
||||
}
|
||||
|
||||
// Determine layout order & random seed
|
||||
let randomSeed = 1;
|
||||
let noRandomize = false;
|
||||
const layoutOrder = printtargLayoutOrder ? printtargLayoutOrder.value : "deterministic";
|
||||
if (layoutOrder === "raster") {
|
||||
noRandomize = true;
|
||||
randomSeed = null;
|
||||
} else if (layoutOrder === "custom_seed") {
|
||||
const parsedSeed = printtargCustomSeed ? parseInt(printtargCustomSeed.value, 10) : 1;
|
||||
randomSeed = isNaN(parsedSeed) || parsedSeed < 1 ? 1 : parsedSeed;
|
||||
noRandomize = false;
|
||||
} else {
|
||||
randomSeed = 1;
|
||||
noRandomize = false;
|
||||
}
|
||||
|
||||
const config = {
|
||||
instrument: instrumentSelect.value,
|
||||
page_size: pageSize,
|
||||
bit_depth: bitDepth,
|
||||
dpi: dpi,
|
||||
custom_label: customLabel,
|
||||
random_seed: randomSeed,
|
||||
no_randomize: noRandomize,
|
||||
basename: stage1Basename,
|
||||
cwd: stage1Cwd,
|
||||
};
|
||||
|
||||
@@ -1206,8 +1206,92 @@ button.danger:hover {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.advanced-section-details {
|
||||
margin-top: 14px;
|
||||
border: 1px solid var(--border-color, #333);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.advanced-section-details summary {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.advanced-section-details summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.advanced-content {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.advanced-group {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.advanced-group-title {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-color, #4facfe);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.advanced-grid-2col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.slider-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.slider-input-container input[type="range"] {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.slider-val-badge {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
min-width: 38px;
|
||||
text-align: right;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.checkbox-control-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.checkbox-control-container label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user