mirror of
https://github.com/typst/typst
synced 2025-08-24 19:54:14 +08:00
feat: [WIP] update test runner
This commit is contained in:
parent
7e6c3b4159
commit
ecda78b17c
@ -6,10 +6,13 @@ use std::str::FromStr;
|
|||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use ecow::{eco_format, EcoString};
|
use ecow::{eco_format, EcoString};
|
||||||
|
use typst::loading::LineCol;
|
||||||
use typst_syntax::package::PackageVersion;
|
use typst_syntax::package::PackageVersion;
|
||||||
use typst_syntax::{is_id_continue, is_ident, is_newline, FileId, Source, VirtualPath};
|
use typst_syntax::{is_id_continue, is_ident, is_newline, FileId, Source, VirtualPath};
|
||||||
use unscanny::Scanner;
|
use unscanny::Scanner;
|
||||||
|
|
||||||
|
use crate::world::{read, system_path};
|
||||||
|
|
||||||
/// Collects all tests from all files.
|
/// Collects all tests from all files.
|
||||||
///
|
///
|
||||||
/// Returns:
|
/// Returns:
|
||||||
@ -79,6 +82,8 @@ impl Display for FileSize {
|
|||||||
pub struct Note {
|
pub struct Note {
|
||||||
pub pos: FilePos,
|
pub pos: FilePos,
|
||||||
pub kind: NoteKind,
|
pub kind: NoteKind,
|
||||||
|
/// The file [`Self::range`] belongs to.
|
||||||
|
pub file: FileId,
|
||||||
pub range: Option<Range<usize>>,
|
pub range: Option<Range<usize>>,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
}
|
}
|
||||||
@ -341,9 +346,31 @@ impl<'a> Parser<'a> {
|
|||||||
let kind: NoteKind = head.parse().ok()?;
|
let kind: NoteKind = head.parse().ok()?;
|
||||||
self.s.eat_if(' ');
|
self.s.eat_if(' ');
|
||||||
|
|
||||||
|
let mut file = None;
|
||||||
|
if self.s.eat_if('"') {
|
||||||
|
let path = self.s.eat_until(|c| is_newline(c) || c == '"');
|
||||||
|
if !self.s.eat_if('"') {
|
||||||
|
self.error("expected closing quote after file path");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vpath = VirtualPath::new(path);
|
||||||
|
file = Some(FileId::new(None, vpath));
|
||||||
|
|
||||||
|
self.s.eat_if(' ');
|
||||||
|
}
|
||||||
|
|
||||||
let mut range = None;
|
let mut range = None;
|
||||||
if self.s.at('-') || self.s.at(char::is_numeric) {
|
if self.s.at('-') || self.s.at(char::is_numeric) || self.s.at('#') {
|
||||||
range = self.parse_range(source);
|
if let Some(file) = file {
|
||||||
|
range = self.parse_range_external(file);
|
||||||
|
} else if !self.s.at('#') {
|
||||||
|
range = self.parse_range(source);
|
||||||
|
} else {
|
||||||
|
self.error("raw byte positions are only allowed in external files");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if range.is_none() {
|
if range.is_none() {
|
||||||
self.error("range is malformed");
|
self.error("range is malformed");
|
||||||
return None;
|
return None;
|
||||||
@ -359,11 +386,79 @@ impl<'a> Parser<'a> {
|
|||||||
Some(Note {
|
Some(Note {
|
||||||
pos: FilePos::new(self.path, self.line),
|
pos: FilePos::new(self.path, self.line),
|
||||||
kind,
|
kind,
|
||||||
|
file: file.unwrap_or(source.id()),
|
||||||
range,
|
range,
|
||||||
message,
|
message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a range in an external file, optionally abbreviated as just a position
|
||||||
|
/// if the range is empty.
|
||||||
|
fn parse_range_external(&mut self, file: FileId) -> Option<Range<usize>> {
|
||||||
|
let path = match system_path(file) {
|
||||||
|
Ok(path) => path,
|
||||||
|
Err(err) => {
|
||||||
|
self.error(err.to_string());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = match read(&path) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(err) => {
|
||||||
|
self.error(err.to_string());
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Allow parsing of byte positions for external files.
|
||||||
|
if self.s.peek() == Some('#') {
|
||||||
|
let start = self.parse_byte_position()?;
|
||||||
|
let end =
|
||||||
|
if self.s.eat_if('-') { self.parse_byte_position()? } else { start };
|
||||||
|
|
||||||
|
if start < 0 || end < 0 {
|
||||||
|
self.error("byte positions must be positive");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Some((start as usize)..(end as usize));
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = self.parse_line_col()?;
|
||||||
|
let range = if self.s.eat_if('-') {
|
||||||
|
let end = self.parse_line_col()?;
|
||||||
|
LineCol::byte_range(start..end, &text)
|
||||||
|
} else {
|
||||||
|
start.byte_pos(&text).map(|i| i..i)
|
||||||
|
};
|
||||||
|
if range.is_none() {
|
||||||
|
self.error("range is out of bounds");
|
||||||
|
}
|
||||||
|
range
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses an absolute `line:column` position in an external file.
|
||||||
|
fn parse_line_col(&mut self) -> Option<LineCol> {
|
||||||
|
let line = self.parse_number()?;
|
||||||
|
if !self.s.eat_if(':') {
|
||||||
|
self.error("positions in external files always require both `<line>:<col>`");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let col = self.parse_number()?;
|
||||||
|
if line < 0 || col < 0 {
|
||||||
|
self.error("line and column numbers must be positive");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(LineCol::one_based(line as usize, col as usize))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a number after a `#` character.
|
||||||
|
fn parse_byte_position(&mut self) -> Option<isize> {
|
||||||
|
self.s.eat_if("#").then(|| self.parse_number()).flatten()
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a range, optionally abbreviated as just a position if the range
|
/// Parse a range, optionally abbreviated as just a position if the range
|
||||||
/// is empty.
|
/// is empty.
|
||||||
fn parse_range(&mut self, source: &Source) -> Option<Range<usize>> {
|
fn parse_range(&mut self, source: &Source) -> Option<Range<usize>> {
|
||||||
|
@ -7,13 +7,15 @@ use tiny_skia as sk;
|
|||||||
use typst::diag::{SourceDiagnostic, Warned};
|
use typst::diag::{SourceDiagnostic, Warned};
|
||||||
use typst::html::HtmlDocument;
|
use typst::html::HtmlDocument;
|
||||||
use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform};
|
use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform};
|
||||||
|
use typst::loading::LineCol;
|
||||||
use typst::visualize::Color;
|
use typst::visualize::Color;
|
||||||
use typst::{Document, WorldExt};
|
use typst::{Document, World, WorldExt};
|
||||||
use typst_pdf::PdfOptions;
|
use typst_pdf::PdfOptions;
|
||||||
|
use typst_syntax::FileId;
|
||||||
|
|
||||||
use crate::collect::{Attr, FileSize, NoteKind, Test};
|
use crate::collect::{Attr, FileSize, NoteKind, Test};
|
||||||
use crate::logger::TestResult;
|
use crate::logger::TestResult;
|
||||||
use crate::world::TestWorld;
|
use crate::world::{system_path, TestWorld};
|
||||||
|
|
||||||
/// Runs a single test.
|
/// Runs a single test.
|
||||||
///
|
///
|
||||||
@ -117,7 +119,7 @@ impl<'a> Runner<'a> {
|
|||||||
if seen {
|
if seen {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let note_range = self.format_range(¬e.range);
|
let note_range = self.format_range(note.file, ¬e.range);
|
||||||
if first {
|
if first {
|
||||||
log!(self, "not emitted");
|
log!(self, "not emitted");
|
||||||
first = false;
|
first = false;
|
||||||
@ -208,10 +210,6 @@ impl<'a> Runner<'a> {
|
|||||||
/// Compare a subset of notes with a given kind against diagnostics of
|
/// Compare a subset of notes with a given kind against diagnostics of
|
||||||
/// that same kind.
|
/// that same kind.
|
||||||
fn check_diagnostic(&mut self, kind: NoteKind, diag: &SourceDiagnostic) {
|
fn check_diagnostic(&mut self, kind: NoteKind, diag: &SourceDiagnostic) {
|
||||||
// Ignore diagnostics from other sources than the test file itself.
|
|
||||||
if diag.span.id().is_some_and(|id| id != self.test.source.id()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// TODO: remove this once HTML export is stable
|
// TODO: remove this once HTML export is stable
|
||||||
if diag.message == "html export is under active development and incomplete" {
|
if diag.message == "html export is under active development and incomplete" {
|
||||||
return;
|
return;
|
||||||
@ -219,11 +217,11 @@ impl<'a> Runner<'a> {
|
|||||||
|
|
||||||
let message = diag.message.replace("\\", "/");
|
let message = diag.message.replace("\\", "/");
|
||||||
let range = self.world.range(diag.span);
|
let range = self.world.range(diag.span);
|
||||||
self.validate_note(kind, range.clone(), &message);
|
self.validate_note(kind, diag.span.id(), range.clone(), &message);
|
||||||
|
|
||||||
// Check hints.
|
// Check hints.
|
||||||
for hint in &diag.hints {
|
for hint in &diag.hints {
|
||||||
self.validate_note(NoteKind::Hint, range.clone(), hint);
|
self.validate_note(NoteKind::Hint, diag.span.id(), range.clone(), hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,15 +233,18 @@ impl<'a> Runner<'a> {
|
|||||||
fn validate_note(
|
fn validate_note(
|
||||||
&mut self,
|
&mut self,
|
||||||
kind: NoteKind,
|
kind: NoteKind,
|
||||||
|
file: Option<FileId>,
|
||||||
range: Option<Range<usize>>,
|
range: Option<Range<usize>>,
|
||||||
message: &str,
|
message: &str,
|
||||||
) {
|
) {
|
||||||
// Try to find perfect match.
|
// Try to find perfect match.
|
||||||
|
let file = file.unwrap_or(self.test.source.id());
|
||||||
if let Some((i, _)) = self.test.notes.iter().enumerate().find(|&(i, note)| {
|
if let Some((i, _)) = self.test.notes.iter().enumerate().find(|&(i, note)| {
|
||||||
!self.seen[i]
|
!self.seen[i]
|
||||||
&& note.kind == kind
|
&& note.kind == kind
|
||||||
&& note.range == range
|
&& note.range == range
|
||||||
&& note.message == message
|
&& note.message == message
|
||||||
|
&& note.file == file
|
||||||
}) {
|
}) {
|
||||||
self.seen[i] = true;
|
self.seen[i] = true;
|
||||||
return;
|
return;
|
||||||
@ -257,7 +258,7 @@ impl<'a> Runner<'a> {
|
|||||||
&& (note.range == range || note.message == message)
|
&& (note.range == range || note.message == message)
|
||||||
}) else {
|
}) else {
|
||||||
// Not even a close match, diagnostic is not annotated.
|
// Not even a close match, diagnostic is not annotated.
|
||||||
let diag_range = self.format_range(&range);
|
let diag_range = self.format_range(file, &range);
|
||||||
log!(into: self.not_annotated, " {kind}: {diag_range} {}", message);
|
log!(into: self.not_annotated, " {kind}: {diag_range} {}", message);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@ -267,10 +268,10 @@ impl<'a> Runner<'a> {
|
|||||||
|
|
||||||
// Range is wrong.
|
// Range is wrong.
|
||||||
if range != note.range {
|
if range != note.range {
|
||||||
let note_range = self.format_range(¬e.range);
|
let note_range = self.format_range(note.file, ¬e.range);
|
||||||
let note_text = self.text_for_range(¬e.range);
|
let note_text = self.text_for_range(note.file, ¬e.range);
|
||||||
let diag_range = self.format_range(&range);
|
let diag_range = self.format_range(note.file, &range);
|
||||||
let diag_text = self.text_for_range(&range);
|
let diag_text = self.text_for_range(note.file, &range);
|
||||||
log!(self, "mismatched range ({}):", note.pos);
|
log!(self, "mismatched range ({}):", note.pos);
|
||||||
log!(self, " message | {}", note.message);
|
log!(self, " message | {}", note.message);
|
||||||
log!(self, " annotated | {note_range:<9} | {note_text}");
|
log!(self, " annotated | {note_range:<9} | {note_text}");
|
||||||
@ -286,39 +287,63 @@ impl<'a> Runner<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Display the text for a range.
|
/// Display the text for a range.
|
||||||
fn text_for_range(&self, range: &Option<Range<usize>>) -> String {
|
fn text_for_range(&self, file: FileId, range: &Option<Range<usize>>) -> String {
|
||||||
let Some(range) = range else { return "No text".into() };
|
let Some(range) = range else { return "No text".into() };
|
||||||
if range.is_empty() {
|
if range.is_empty() {
|
||||||
"(empty)".into()
|
"(empty)".into()
|
||||||
|
} else if file == self.test.source.id() {
|
||||||
|
format!(
|
||||||
|
"`{}`",
|
||||||
|
self.test.source.text()[range.clone()]
|
||||||
|
.replace('\n', "\\n")
|
||||||
|
.replace('\r', "\\r")
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("`{}`", self.test.source.text()[range.clone()].replace('\n', "\\n"))
|
let bytes = self.world.file(file).unwrap();
|
||||||
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
|
format!("`{}`", text[range.clone()].replace('\n', "\\n").replace('\r', "\\r"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display a byte range as a line:column range.
|
/// Display a byte range as a line:column range.
|
||||||
fn format_range(&self, range: &Option<Range<usize>>) -> String {
|
fn format_range(&self, file: FileId, range: &Option<Range<usize>>) -> String {
|
||||||
let Some(range) = range else { return "No range".into() };
|
let Some(range) = range else { return "No range".into() };
|
||||||
|
|
||||||
|
let mut preamble = String::new();
|
||||||
|
if file != self.test.source.id() {
|
||||||
|
preamble = format!("\"{}\" ", system_path(file).unwrap().display());
|
||||||
|
}
|
||||||
|
|
||||||
if range.start == range.end {
|
if range.start == range.end {
|
||||||
self.format_pos(range.start)
|
format!("{preamble}{}", self.format_pos(file, range.start))
|
||||||
} else {
|
} else {
|
||||||
format!("{}-{}", self.format_pos(range.start,), self.format_pos(range.end,))
|
format!(
|
||||||
|
"{preamble}{}-{}",
|
||||||
|
self.format_pos(file, range.start),
|
||||||
|
self.format_pos(file, range.end)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display a position as a line:column pair.
|
/// Display a position as a line:column pair.
|
||||||
fn format_pos(&self, pos: usize) -> String {
|
fn format_pos(&self, file: FileId, pos: usize) -> String {
|
||||||
if let (Some(line_idx), Some(column_idx)) =
|
let res = if file != self.test.source.id() {
|
||||||
(self.test.source.byte_to_line(pos), self.test.source.byte_to_column(pos))
|
let bytes = self.world.file(file).unwrap();
|
||||||
{
|
LineCol::from_byte_pos(pos, &bytes).map(|l| l.numbers())
|
||||||
let line = self.test.pos.line + line_idx;
|
|
||||||
let column = column_idx + 1;
|
|
||||||
if line == 1 {
|
|
||||||
format!("{column}")
|
|
||||||
} else {
|
|
||||||
format!("{line}:{column}")
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
"oob".into()
|
let line = self.test.source.byte_to_line(pos).map(|l| l + 1);
|
||||||
|
let col = (self.test.source.byte_to_column(pos))
|
||||||
|
.map(|c| self.test.pos.line + c + 1);
|
||||||
|
Option::zip(line, col)
|
||||||
|
};
|
||||||
|
let Some((line, col)) = res else {
|
||||||
|
return "oob".into();
|
||||||
|
};
|
||||||
|
|
||||||
|
if line == 1 {
|
||||||
|
format!("{col}")
|
||||||
|
} else {
|
||||||
|
format!("{line}:{col}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,7 +149,7 @@ impl FileSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The file system path for a file ID.
|
/// The file system path for a file ID.
|
||||||
fn system_path(id: FileId) -> FileResult<PathBuf> {
|
pub(crate) fn system_path(id: FileId) -> FileResult<PathBuf> {
|
||||||
let root: PathBuf = match id.package() {
|
let root: PathBuf = match id.package() {
|
||||||
Some(spec) => format!("tests/packages/{}-{}", spec.name, spec.version).into(),
|
Some(spec) => format!("tests/packages/{}-{}", spec.name, spec.version).into(),
|
||||||
None => PathBuf::new(),
|
None => PathBuf::new(),
|
||||||
@ -159,7 +159,7 @@ fn system_path(id: FileId) -> FileResult<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Read a file.
|
/// Read a file.
|
||||||
fn read(path: &Path) -> FileResult<Cow<'static, [u8]>> {
|
pub(crate) fn read(path: &Path) -> FileResult<Cow<'static, [u8]>> {
|
||||||
// Resolve asset.
|
// Resolve asset.
|
||||||
if let Ok(suffix) = path.strip_prefix("assets/") {
|
if let Ok(suffix) = path.strip_prefix("assets/") {
|
||||||
return typst_dev_assets::get(&suffix.to_string_lossy())
|
return typst_dev_assets::get(&suffix.to_string_lossy())
|
||||||
|
@ -18,12 +18,12 @@
|
|||||||
#csv("nope.csv")
|
#csv("nope.csv")
|
||||||
|
|
||||||
--- csv-invalid ---
|
--- csv-invalid ---
|
||||||
// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3)
|
// Error: "/assets/data/bad.csv" 3:1 failed to parse CSV (found 3 instead of 2 fields in line 3)
|
||||||
#csv("/assets/data/bad.csv")
|
#csv("/assets/data/bad.csv")
|
||||||
|
|
||||||
--- csv-invalid-row-type-dict ---
|
--- csv-invalid-row-type-dict ---
|
||||||
// Test error numbering with dictionary rows.
|
// Test error numbering with dictionary rows.
|
||||||
// Error: 6-28 failed to parse CSV (found 3 instead of 2 fields in line 3)
|
// Error: "/assets/data/bad.csv" 3:1 failed to parse CSV (found 3 instead of 2 fields in line 3)
|
||||||
#csv("/assets/data/bad.csv", row-type: dictionary)
|
#csv("/assets/data/bad.csv", row-type: dictionary)
|
||||||
|
|
||||||
--- csv-invalid-delimiter ---
|
--- csv-invalid-delimiter ---
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
#test(data.at(2).weight, 150)
|
#test(data.at(2).weight, 150)
|
||||||
|
|
||||||
--- json-invalid ---
|
--- json-invalid ---
|
||||||
// Error: 7-30 failed to parse JSON (expected value at line 3 column 14)
|
// Error: "/assets/data/bad.json" 3:14 failed to parse JSON (expected value at line 3 column 14)
|
||||||
#json("/assets/data/bad.json")
|
#json("/assets/data/bad.json")
|
||||||
|
|
||||||
--- json-decode-deprecated ---
|
--- json-decode-deprecated ---
|
||||||
|
@ -37,7 +37,7 @@
|
|||||||
))
|
))
|
||||||
|
|
||||||
--- toml-invalid ---
|
--- toml-invalid ---
|
||||||
// Error: 7-30 failed to parse TOML (expected `.`, `=` at line 1 column 16)
|
// Error: "/assets/data/bad.toml" #15-#16 failed to parse TOML (expected `.`, `=`)
|
||||||
#toml("/assets/data/bad.toml")
|
#toml("/assets/data/bad.toml")
|
||||||
|
|
||||||
--- toml-decode-deprecated ---
|
--- toml-decode-deprecated ---
|
||||||
|
@ -24,7 +24,7 @@
|
|||||||
),))
|
),))
|
||||||
|
|
||||||
--- xml-invalid ---
|
--- xml-invalid ---
|
||||||
// Error: 6-28 failed to parse XML (found closing tag 'data' instead of 'hello' in line 3)
|
// Error: "/assets/data/bad.xml" 3:0 failed to parse XML (found closing tag 'data' instead of 'hello')
|
||||||
#xml("/assets/data/bad.xml")
|
#xml("/assets/data/bad.xml")
|
||||||
|
|
||||||
--- xml-decode-deprecated ---
|
--- xml-decode-deprecated ---
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
#test(data.at("1"), "ok")
|
#test(data.at("1"), "ok")
|
||||||
|
|
||||||
--- yaml-invalid ---
|
--- yaml-invalid ---
|
||||||
// Error: 7-30 failed to parse YAML (did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 18)
|
// Error: "/assets/data/bad.yaml" 2:1 failed to parse YAML (did not find expected ',' or ']' at line 2 column 1, while parsing a flow sequence at line 1 column 18)
|
||||||
#yaml("/assets/data/bad.yaml")
|
#yaml("/assets/data/bad.yaml")
|
||||||
|
|
||||||
--- yaml-decode-deprecated ---
|
--- yaml-decode-deprecated ---
|
||||||
|
@ -334,6 +334,7 @@
|
|||||||
|
|
||||||
--- import-cyclic-in-other-file ---
|
--- import-cyclic-in-other-file ---
|
||||||
// Cyclic import in other file.
|
// Cyclic import in other file.
|
||||||
|
// error: "./modules/cycle1.typ" 2:29-2:51 file is not valid utf-8
|
||||||
#import "./modules/cycle1.typ": *
|
#import "./modules/cycle1.typ": *
|
||||||
|
|
||||||
This is never reached.
|
This is never reached.
|
||||||
|
@ -167,7 +167,7 @@ A #box(image("/assets/images/tiger.jpg", height: 1cm, width: 80%)) B
|
|||||||
#image("/assets/plugins/hello.wasm")
|
#image("/assets/plugins/hello.wasm")
|
||||||
|
|
||||||
--- image-bad-svg ---
|
--- image-bad-svg ---
|
||||||
// Error: 2-33 failed to parse SVG (found closing tag 'g' instead of 'style' in line 4)
|
// Error: "/assets/images/bad.svg" 4:0 failed to parse SVG (found closing tag 'g' instead of 'style' at 4:3)
|
||||||
#image("/assets/images/bad.svg")
|
#image("/assets/images/bad.svg")
|
||||||
|
|
||||||
--- image-decode-svg ---
|
--- image-decode-svg ---
|
||||||
|
Loading…
x
Reference in New Issue
Block a user