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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ jobs:
--output-dir /tmp/tikzarium-ci/multipage \
--expect-first 'Привет' \
--expect-second 'Геометрия'
- name: Audit XeLaTeX PGF/TikZ extension fixtures with actual artifacts
run: |
python3 -m unittest discover -s extensions/qa -p 'test_run_smoke.py' -v
python3 -m unittest discover -s extensions/qa -p 'test_verify_smoke.py' -v
python3 extensions/qa/run_smoke.py --output /tmp/tikzarium-ci/extensions-c03
python3 extensions/qa/verify_smoke.py \
--output /tmp/tikzarium-ci/extensions-c03 \
--report /tmp/tikzarium-ci/extensions-c03/audit.json
- name: Upload smoke PDFs and Unicode evidence
if: always()
uses: actions/upload-artifact@v4
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/qa-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ jobs:
collection-xetex
collection-latexrecommended
collection-pictures
pgfplots
circuitikz
tikz-cd
standalone
unicode-math
lm-math
Expand Down Expand Up @@ -135,6 +138,12 @@ jobs:
run: cargo build -p tikzarium-cli
- name: Execute fixture suite with machine-readable evidence
run: python scripts/tools/qa_smoke.py --cli "target/debug/${{ matrix.cli }}" --output ".qa-artifacts/${{ matrix.label }}"
- name: "Extension smoke — record and audit five exact XeLaTeX fixtures"
run: |
python -m unittest discover -s extensions/qa -p "test_run_smoke.py" -v
python -m unittest discover -s extensions/qa -p "test_verify_smoke.py" -v
python extensions/qa/run_smoke.py --output ".qa-artifacts/${{ matrix.label }}/extensions"
python extensions/qa/verify_smoke.py --output ".qa-artifacts/${{ matrix.label }}/extensions" --report ".qa-artifacts/${{ matrix.label }}/extensions/audit.json"
- name: Capture TeX installer failure log, if present
if: failure() && steps.texlive_install.outcome == 'failure'
run: python -c "from pathlib import Path; import shutil; d=Path('.qa-artifacts/${{ matrix.label }}'); d.mkdir(parents=True,exist_ok=True); s=Path('install-tl.log'); shutil.copyfile(s,d/'install-tl.log') if s.is_file() else (d/'installer-log-unavailable.txt').write_text('TeX installation failed before producing install-tl.log', encoding='utf-8')"
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## Unreleased — 2026-10-31: I05 hardened XeLaTeX runner and extension evidence

- Correct the Windows-hosted XeLaTeX `./filename.tex` source operand: never construct it using Windows-native `Path::join` (`.\\filename.tex` is a TeX control-sequence hazard). Use an `OsString` literal `./` prefix and regression-test ordinary, leading-dash and Cyrillic filenames. Re-run cross-host CI against the exact new SHA; no Win10 acceptance claim.

- Reconciled former A #10 runner improvements with F02 font-warning detection: XeLaTeX retains structured file+line diagnostics, optional durable logs, source-safe atomic output and recorder paths.
- GUI error panel uses structured failures and refuses current-buffer line jumps for diagnostics belonging to a different included TeX file.
- C03 verifier now runs after real five-fixture extension XeLaTeX observation in Linux CI and on each hosted Windows Server/macOS ARM/Linux D QA job. Stored artifact hashes and Lua-only negative are audited, not merely reported.
- Exact-head CI and uploaded evidence are PENDING at initial integration. Native Windows 10 and M1/M4 GUI remain NOT_RUN. No v0.1 release claim.


## Unreleased — 2026-10-31: I04 two-page PDF integration gate

- Added `scripts/qa_multipage_pdf.py` for actual Rust CLI → XeLaTeX → Poppler two-page evidence, including page-specific Cyrillic text and distinct 120-dpi PNG hashes.
Expand Down Expand Up @@ -36,3 +46,5 @@ Unreleased development state; no production release.

- Four-crate Rust workspace, egui shell, XeLaTeX core/CLI, package catalogue and hosted CI in draft PR #6.
- Independent QA PR #9 documented the missing-Cyrillic-glyph false positive.

- I05 QA found TeX Live 2026/PGF 3.1.12 wraps the required LuaTeX graphdrawing diagnostic **inside the word graph**; narrowed negative-control matching to the full phrase with allowed TeX line wraps, with targeted regression tests. Previous TeX Live 2023 five-fixture artifact audit remained green. Fresh head CI pending.
54 changes: 37 additions & 17 deletions crates/tikzarium-app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ mod source_fonts;
mod ui_state;
mod ui_theme;
use ui_state::{
BottomView, EditorCommand, WorkspaceMode, editor_command, line_start_char_index,
preview_is_stale, scaled_zoom,
BottomView, EditorCommand, WorkspaceMode, diagnostic_targets_current_file, editor_command,
line_start_char_index, preview_is_stale, scaled_zoom,
};

use font_proof::{FontProofResult, spawn_font_proof};
Expand Down Expand Up @@ -345,20 +345,32 @@ impl TikzariumApp {
}
Err(error) => {
self.last_build_failed = true;
self.diagnostics = vec![BuildDiagnostic {
severity: Severity::Error,
message: error.to_string(),
line: None,
}];
self.diagnostics = error.diagnostics().to_vec();
if self.diagnostics.is_empty() {
self.diagnostics = vec![BuildDiagnostic {
severity: Severity::Error,
message: error.to_string(),
source: None,
line: None,
}];
}
self.bottom_view = BottomView::Issues;
self.show_log = true;
self.log = error.to_string();
self.log = error
.log_excerpt()
.map(str::to_owned)
.unwrap_or_else(|| error.to_string());
self.preview_status = if self.pdf.is_some() {
"Build failed — last good PDF retained (stale)".into()
} else {
"Build failed — no PDF available".into()
};
self.status = format!("Build failed: {error}");
let summary = error.to_string();
let first_line = summary.lines().next().unwrap_or("XeLaTeX error");
self.status = format!(
"Build failed: {}",
first_line.chars().take(160).collect::<String>()
);
}
}
}
Expand Down Expand Up @@ -957,21 +969,29 @@ impl eframe::App for TikzariumApp {
Severity::Error => "ERROR",
Severity::Warning => "WARNING",
};
let label = match item.line {
Some(line) => {
format!("{severity} · line {line} · {}", item.message)
}
None => format!("{severity} · {}", item.message),
let location = match (item.source.as_ref(), item.line) {
(Some(source), Some(line)) => format!("{}:{line}", source.display()),
(Some(source), None) => source.display().to_string(),
(None, Some(line)) => format!("line {line}, file unknown"),
(None, None) => "location unknown".to_owned(),
};
if let Some(line) = item.line {
if ui.button(label).clicked() {
let label = format!("{severity} · {location} · {}", item.message);
let active_source = diagnostic_targets_current_file(
item.source.as_deref(),
self.document.path.as_deref(),
);
if active_source {
if let Some(line) = item.line
&& ui.button(&label).clicked()
{
self.jump_to_line = Some(line);
if !self.workspace.show_source() {
self.workspace = WorkspaceMode::Split;
}
}
} else {
ui.label(label);
ui.label(label)
.on_hover_text("Open the referenced file to navigate; unknown source is not guessed.");
}
}
}
Expand Down
48 changes: 48 additions & 0 deletions crates/tikzarium-app/src/ui_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ pub fn editor_command(key: Key, modifiers: Modifiers) -> Option<EditorCommand> {
}
}

/// Only navigate an error when the logged source can be identified as the open file.
/// Relative TeX paths are resolved against the current document directory.
/// For an unknown file we deliberately refuse to jump to a possibly wrong line.
#[must_use]
pub fn diagnostic_targets_current_file(source: Option<&Path>, current: Option<&Path>) -> bool {
let (Some(source), Some(current)) = (source, current) else {
return false;
};
let absolute = if source.is_absolute() {
source.to_path_buf()
} else {
current
.parent()
.unwrap_or_else(|| Path::new("."))
.join(source)
};
match (absolute.canonicalize(), current.canonicalize()) {
(Ok(a), Ok(b)) => a == b,
_ => absolute == current,
}
}

/// Keep zoom finite and inside a user-visible range, including corrupt state.
#[must_use]
pub fn scaled_zoom(current: f32, factor: f32) -> f32 {
Expand All @@ -103,6 +125,32 @@ pub fn scaled_zoom(current: f32, factor: f32) -> f32 {
mod tests {
use super::*;

#[test]
fn failed_build_diagnostics_never_jump_to_an_included_file() {
let dir = tempfile::tempdir().unwrap();
let main = dir.path().join("main.tex");
let include = dir.path().join("included.tex");
std::fs::write(&main, "% main").unwrap();
std::fs::write(&include, "% included").unwrap();
assert!(diagnostic_targets_current_file(
Some(Path::new("./main.tex")),
Some(&main)
));
assert!(!diagnostic_targets_current_file(
Some(Path::new("included.tex")),
Some(&main)
));
assert!(!diagnostic_targets_current_file(
Some(&include),
Some(&main)
));
assert!(!diagnostic_targets_current_file(None, Some(&main)));
assert!(!diagnostic_targets_current_file(
Some(Path::new("main.tex")),
None
));
}

#[test]
fn source_canvas_and_split_have_explicit_boundaries() {
assert!(WorkspaceMode::Source.show_source());
Expand Down
2 changes: 2 additions & 0 deletions crates/tikzarium-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
tikzarium-core = { path = "../tikzarium-core" }
tempfile.workspace = true
105 changes: 98 additions & 7 deletions crates/tikzarium-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,51 @@
use std::{env, fs, path::PathBuf, process::ExitCode};
use std::{
env, fs,
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
};
use tikzarium_core::{BuildOptions, BuildRequest, BuildService, TexEnvironment};

fn usage() {
eprintln!("Usage: tikzarium-cli doctor | build <source.tex> <output.pdf>");
eprintln!(
"Usage: tikzarium-cli doctor | build <source.tex> <output.pdf> [--log <logfile.log>]"
);
}

fn safe_pdf_target(source: &Path, destination: &Path) -> bool {
if !destination
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
{
return false;
}
match (source.canonicalize(), destination.canonicalize()) {
(Ok(source), Ok(destination)) => source != destination,
_ => true,
}
}

fn write_pdf(destination: &Path, pdf: &[u8]) -> io::Result<()> {
let directory = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(directory)?;
let mut temporary = tempfile::NamedTempFile::new_in(directory)?;
temporary.write_all(pdf)?;
temporary.flush()?;
temporary.as_file().sync_all()?;
temporary
.persist(destination)
.map_err(|error| error.error)?;
Ok(())
}

fn main() -> ExitCode {
let args: Vec<String> = env::args().collect();
match args.get(1).map(String::as_str) {
Some("doctor") => {
Some("doctor") if args.len() == 2 => {
let env = TexEnvironment::discover();
println!("XeLaTeX: {:?}", env.xelatex);
println!("Version: {:?}", env.xelatex_version);
Expand All @@ -21,34 +58,62 @@ fn main() -> ExitCode {
ExitCode::from(2)
}
}
Some("build") if args.len() == 4 => {
Some("build") if args.len() == 4 || args.len() == 6 => {
let source = PathBuf::from(&args[2]);
let destination = PathBuf::from(&args[3]);
if !safe_pdf_target(&source, &destination) {
eprintln!("Refusing output path: expected a .pdf that is not the TeX input.");
return ExitCode::from(4);
}
let log_path = if args.len() == 6 && args[4] == "--log" {
let path = PathBuf::from(&args[5]);
if !path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("log"))
{
eprintln!("Persistent build log must use the .log extension.");
return ExitCode::from(1);
}
Some(path)
} else if args.len() == 4 {
None
} else {
usage();
return ExitCode::from(1);
};

let env = TexEnvironment::discover();
let Some(xelatex) = env.xelatex else {
eprintln!("XeLaTeX missing. Install TeX Live/MiKTeX, or set TIKZARIUM_XELATEX.");
return ExitCode::from(2);
};
let req = BuildRequest {
source: PathBuf::from(&args[2]),
source,
xelatex,
pdf_renderer: env.pdf_renderer,
options: BuildOptions {
rasterize_first_page: false,
log_path: log_path.clone(),
..BuildOptions::default()
},
};
match BuildService::new().run(&req) {
Ok(artifact) => match fs::write(&args[3], &artifact.pdf) {
Ok(artifact) => match write_pdf(&destination, &artifact.pdf) {
Ok(()) => {
println!(
"Built {} bytes in {:?}; {} dependencies",
artifact.pdf.len(),
artifact.elapsed,
artifact.dependencies.len()
);
if let Some(log) = log_path {
println!("Persistent TeX log: {}", log.display());
}
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("Cannot write output: {error}");
eprintln!("Cannot atomically publish PDF: {error}");
ExitCode::from(4)
}
},
Expand All @@ -64,3 +129,29 @@ fn main() -> ExitCode {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn never_overwrite_input_or_non_pdf_with_compiled_bytes() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("example.tex");
fs::write(&source, "source").unwrap();
assert!(!safe_pdf_target(&source, &source));
assert!(!safe_pdf_target(&source, &dir.path().join("included.tex")));
assert!(safe_pdf_target(&source, &dir.path().join("result.pdf")));
}

#[test]
fn atomic_pdf_write_does_not_mutate_source() {
let dir = tempfile::tempdir().unwrap();
let original = dir.path().join("original.tex");
fs::write(&original, b"% original\r\n").unwrap();
let output = dir.path().join("result.pdf");
write_pdf(&output, b"%PDF-1.4\n").unwrap();
assert_eq!(fs::read(&original).unwrap(), b"% original\r\n");
assert_eq!(fs::read(&output).unwrap(), b"%PDF-1.4\n");
}
}
Loading
Loading