Add MARD pattern image import

This commit is contained in:
wuyanwanwu
2026-08-14 22:31:53 +08:00
parent 785a089518
commit a65536e31e
16 changed files with 416 additions and 99 deletions
+17 -1
View File
@@ -52,7 +52,10 @@ button { color: inherit; }
.upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; font-size: 12px; } .upload-overlay { position: absolute; z-index: 3; right: 10px; bottom: 10px; padding: 7px 10px; border: 0; color: white; background: rgba(22, 75, 57, .9); cursor: pointer; font-size: 12px; }
.file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; } .file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin: 9px 0 24px; color: var(--muted); font-size: 11px; }
.crop-select-button { width: 100%; margin-top: 0; padding: 10px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-size: 12px; font-weight: 700; } .crop-select-button { width: 100%; margin-top: 0; padding: 10px; border: 1px solid var(--ink); background: var(--lime); cursor: pointer; font-size: 12px; font-weight: 700; }
.reset-crop { width: 100%; margin: 7px 0 20px; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; } .reset-crop { width: 100%; margin: 7px 0 0; padding: 7px; border: 1px solid var(--line); background: white; cursor: pointer; color: var(--muted); font-size: 11px; }
.pattern-import-button { width: 100%; margin: 7px 0 20px; padding: 9px; border: 1px solid #b6532d; background: #fff5ec; color: #8f3f20; cursor: pointer; font-size: 12px; font-weight: 700; }
.pattern-import-button:hover { background: #fee5d1; }
.pattern-import-file { display: none; }
.field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; } .field-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 8px; }
.field-row label, .field { display: flex; flex-direction: column; gap: 8px; } .field-row label, .field { display: flex; flex-direction: column; gap: 8px; }
.field-row label span, .field > span { color: var(--muted); font-size: 12px; } .field-row label span, .field > span { color: var(--muted); font-size: 12px; }
@@ -150,6 +153,12 @@ input[type="range"] { accent-color: var(--green); }
.crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; } .crop-mode-switch button { min-height: 34px; padding: 0 15px; border: 0; border-right: 1px solid var(--ink); background: transparent; cursor: pointer; font-size: 12px; }
.crop-mode-switch button:last-child { border-right: 0; } .crop-mode-switch button:last-child { border-right: 0; }
.crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; } .crop-mode-switch button.active { background: var(--green); color: white; font-weight: 700; }
.pattern-import-dimensions { display: flex; align-items: flex-end; gap: 8px; }
.pattern-import-dimensions label { display: grid; gap: 3px; color: var(--muted); font-size: 10px; }
.pattern-import-dimensions input { width: 82px; height: 34px; padding: 0 8px; border: 1px solid var(--line); background: white; color: var(--ink); font-size: 12px; }
.pattern-import-dimensions b { padding-bottom: 9px; color: var(--muted); font-weight: 400; }
.pattern-import-empty-option { display: flex; align-items: center; gap: 6px; color: var(--ink); cursor: pointer; }
.pattern-import-empty-option input { accent-color: var(--green); }
.crop-size-readout { margin-left: auto; font-variant-numeric: tabular-nums; } .crop-size-readout { margin-left: auto; font-variant-numeric: tabular-nums; }
.crop-dialog-scroll { min-height: 0; flex: 1 1 auto; overflow: auto; padding: 18px; display: grid; place-items: center; background-color: #e9e5db; background-image: radial-gradient(#c8c3b8 .7px, transparent .7px); background-size: 12px 12px; } .crop-dialog-scroll { min-height: 0; flex: 1 1 auto; overflow: auto; padding: 18px; display: grid; place-items: center; background-color: #e9e5db; background-image: radial-gradient(#c8c3b8 .7px, transparent .7px); background-size: 12px 12px; }
.crop-dialog-stage { position: relative; width: max-content; max-width: 100%; line-height: 0; cursor: crosshair; touch-action: none; user-select: none; box-shadow: 0 0 0 1px var(--ink); } .crop-dialog-stage { position: relative; width: max-content; max-width: 100%; line-height: 0; cursor: crosshair; touch-action: none; user-select: none; box-shadow: 0 0 0 1px var(--ink); }
@@ -165,6 +174,7 @@ input[type="range"] { accent-color: var(--green); }
.crop-selection-box::before, .crop-selection-box::after { content: ""; position: absolute; background: rgba(255,255,255,.62); } .crop-selection-box::before, .crop-selection-box::after { content: ""; position: absolute; background: rgba(255,255,255,.62); }
.crop-selection-box::before { left: 33.333%; top: 0; bottom: 0; width: 1px; box-shadow: calc(33.333% * 1) 0 0 rgba(255,255,255,.62); } .crop-selection-box::before { left: 33.333%; top: 0; bottom: 0; width: 1px; box-shadow: calc(33.333% * 1) 0 0 rgba(255,255,255,.62); }
.crop-selection-box::after { left: 0; right: 0; top: 33.333%; height: 1px; box-shadow: 0 calc(33.333% * 1) 0 rgba(255,255,255,.62); } .crop-selection-box::after { left: 0; right: 0; top: 33.333%; height: 1px; box-shadow: 0 calc(33.333% * 1) 0 rgba(255,255,255,.62); }
.pattern-import-grid-overlay { position: absolute; z-index: 3; inset: 0; display: block; pointer-events: none; background-image: linear-gradient(to right, rgba(255,102,0,.66) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,102,0,.66) 1px, transparent 1px); box-shadow: inset -1px -1px 0 rgba(255,102,0,.66); }
.crop-resize-handle { position: absolute; z-index: 4; width: 15px; height: 15px; display: block; border: 2px solid white; border-radius: 50%; background: var(--green); box-shadow: 0 1px 4px rgba(0,0,0,.48); pointer-events: auto; } .crop-resize-handle { position: absolute; z-index: 4; width: 15px; height: 15px; display: block; border: 2px solid white; border-radius: 50%; background: var(--green); box-shadow: 0 1px 4px rgba(0,0,0,.48); pointer-events: auto; }
.handle-nw { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; } .handle-nw { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; }
.handle-ne { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; } .handle-ne { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
@@ -222,10 +232,16 @@ input[type="range"] { accent-color: var(--green); }
.crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; } .crop-mode-bar { padding: 8px 12px; flex-wrap: wrap; gap: 7px 10px; }
.crop-mode-switch { flex: 1; } .crop-mode-switch { flex: 1; }
.crop-mode-switch button { flex: 1; padding: 0 8px; } .crop-mode-switch button { flex: 1; padding: 0 8px; }
.pattern-import-dimensions { width: 100%; }
.pattern-import-dimensions label { flex: 1; }
.pattern-import-dimensions input { width: 100%; min-height: 42px; }
.pattern-import-empty-option { width: 100%; min-height: 34px; }
.crop-size-readout { width: 100%; margin-left: 0; } .crop-size-readout { width: 100%; margin-left: 0; }
.crop-resize-handle { width: 28px; height: 28px; border-width: 3px; } .crop-resize-handle { width: 28px; height: 28px; border-width: 3px; }
.crop-edge-handle { display: none; } .crop-edge-handle { display: none; }
.crop-dialog-scroll { padding: 10px; } .crop-dialog-scroll { padding: 10px; }
.crop-dialog-stage { width: 100%; max-width: 100%; }
.crop-dialog-stage canvas { width: 100%; max-width: 100%; height: auto; }
.crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; } .crop-dialog-actions { padding: 10px 12px; flex-wrap: wrap; }
.crop-dialog-actions span { width: 100%; order: -1; } .crop-dialog-actions span { width: 100%; order: -1; }
.crop-dialog-actions button { flex: 1; padding: 0 10px; } .crop-dialog-actions button { flex: 1; padding: 0 10px; }
+358 -13
View File
@@ -36,6 +36,7 @@ type CropRect = {
}; };
type CropSelectionMode = "free" | "square"; type CropSelectionMode = "free" | "square";
type CropDialogPurpose = "source" | "pattern-import";
type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se"; type CropResizeHandle = "n" | "s" | "e" | "w" | "nw" | "ne" | "sw" | "se";
@@ -228,6 +229,231 @@ function nearestColor(lab: Oklab, palette: BeadColor[]) {
return closest; return closest;
} }
type NormalizedGlyph = {
pixels: Uint8Array;
count: number;
};
let mardCodeTemplates: Array<{ code: string; glyph: NormalizedGlyph }> | null = null;
function normalizeGlyph(mask: Uint8Array, width: number, height: number, targetWidth = 32, targetHeight = 16): NormalizedGlyph | null {
let left = width;
let right = -1;
let top = height;
let bottom = -1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (!mask[y * width + x]) continue;
left = Math.min(left, x);
right = Math.max(right, x);
top = Math.min(top, y);
bottom = Math.max(bottom, y);
}
}
if (right < left || bottom < top) return null;
const sourceWidth = right - left + 1;
const sourceHeight = bottom - top + 1;
const scale = Math.min((targetWidth - 2) / sourceWidth, (targetHeight - 2) / sourceHeight);
const drawWidth = Math.max(1, Math.round(sourceWidth * scale));
const drawHeight = Math.max(1, Math.round(sourceHeight * scale));
const offsetX = Math.floor((targetWidth - drawWidth) / 2);
const offsetY = Math.floor((targetHeight - drawHeight) / 2);
const pixels = new Uint8Array(targetWidth * targetHeight);
let count = 0;
for (let y = 0; y < drawHeight; y++) {
for (let x = 0; x < drawWidth; x++) {
const sourceX = left + Math.min(sourceWidth - 1, Math.floor(x / scale));
const sourceY = top + Math.min(sourceHeight - 1, Math.floor(y / scale));
if (!mask[sourceY * width + sourceX]) continue;
pixels[(offsetY + y) * targetWidth + offsetX + x] = 1;
count += 1;
}
}
return count >= 3 ? { pixels, count } : null;
}
function buildMardCodeTemplates() {
if (mardCodeTemplates) return mardCodeTemplates;
const canvas = document.createElement("canvas");
canvas.width = 96;
canvas.height = 48;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
mardCodeTemplates = PALETTE.map((color) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000000";
ctx.font = "700 28px Arial";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(color.code, canvas.width / 2, canvas.height / 2);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const mask = new Uint8Array(canvas.width * canvas.height);
for (let index = 0; index < mask.length; index++) {
const offset = index * 4;
const luminance = data[offset] * 0.299 + data[offset + 1] * 0.587 + data[offset + 2] * 0.114;
if (luminance < 150) mask[index] = 1;
}
return { code: color.code, glyph: normalizeGlyph(mask, canvas.width, canvas.height)! };
});
return mardCodeTemplates;
}
function glyphSimilarity(first: NormalizedGlyph, second: NormalizedGlyph) {
let intersection = 0;
for (let index = 0; index < first.pixels.length; index++) {
if (first.pixels[index] && second.pixels[index]) intersection += 1;
}
return (2 * intersection) / Math.max(1, first.count + second.count);
}
function dominantCellBackground(data: Uint8ClampedArray, canvasWidth: number, startX: number, startY: number, cellSize: number) {
const bins = new Map<number, { count: number; red: number; green: number; blue: number; alpha: number }>();
const inset = Math.max(2, Math.floor(cellSize * 0.12));
for (let y = inset; y < cellSize - inset; y++) {
for (let x = inset; x < cellSize - inset; x++) {
const nx = x / cellSize;
const ny = y / cellSize;
if (nx > 0.34 && nx < 0.66 && ny > 0.27 && ny < 0.73) continue;
const index = ((startY + y) * canvasWidth + startX + x) * 4;
const alpha = data[index + 3];
const key = alpha < TRANSPARENT_ALPHA_THRESHOLD
? -1
: ((data[index] >> 4) << 8) | ((data[index + 1] >> 4) << 4) | (data[index + 2] >> 4);
const bin = bins.get(key) ?? { count: 0, red: 0, green: 0, blue: 0, alpha: 0 };
bin.count += 1;
bin.red += data[index];
bin.green += data[index + 1];
bin.blue += data[index + 2];
bin.alpha += alpha;
bins.set(key, bin);
}
}
const dominant = [...bins.values()].sort((a, b) => b.count - a.count)[0];
if (!dominant) return { rgb: [255, 255, 255] as [number, number, number], alpha: 0 };
return {
rgb: [
Math.round(dominant.red / dominant.count),
Math.round(dominant.green / dominant.count),
Math.round(dominant.blue / dominant.count),
] as [number, number, number],
alpha: Math.round(dominant.alpha / dominant.count),
};
}
function extractCellGlyph(
data: Uint8ClampedArray,
canvasWidth: number,
startX: number,
startY: number,
cellSize: number,
background: [number, number, number],
) {
const mask = new Uint8Array(cellSize * cellSize);
const marginX = Math.max(2, Math.floor(cellSize * 0.12));
const marginY = Math.max(2, Math.floor(cellSize * 0.16));
for (let y = marginY; y < cellSize - marginY; y++) {
for (let x = marginX; x < cellSize - marginX; x++) {
const index = ((startY + y) * canvasWidth + startX + x) * 4;
if (data[index + 3] < TRANSPARENT_ALPHA_THRESHOLD) continue;
const difference = Math.hypot(
data[index] - background[0],
data[index + 1] - background[1],
data[index + 2] - background[2],
);
if (difference >= 72) mask[y * cellSize + x] = 1;
}
}
return normalizeGlyph(mask, cellSize, cellSize);
}
function recognizeMardCode(glyph: NormalizedGlyph, backgroundLab: Oklab) {
let bestCode = "";
let bestScore = 0;
const candidates = buildMardCodeTemplates()
.map((template) => ({ template, distance: oklabDistance(backgroundLab, PALETTE_BY_CODE.get(template.code)!.lab) }))
.sort((first, second) => first.distance - second.distance)
.slice(0, 24);
for (const { template, distance } of candidates) {
const rawScore = glyphSimilarity(glyph, template.glyph);
const colorPenalty = Math.min(0.16, distance * 0.35);
const score = rawScore - colorPenalty;
if (score > bestScore) {
bestScore = score;
bestCode = template.code;
}
}
return { code: bestCode, score: bestScore };
}
function importMardPatternImage(
image: HTMLImageElement,
crop: CropRect,
width: number,
height: number,
missingCodeAsEmpty: boolean,
) {
const maximumSide = 4096;
const cellSize = Math.max(10, Math.min(36, Math.floor(maximumSide / Math.max(width, height))));
const canvas = document.createElement("canvas");
canvas.width = width * cellSize;
canvas.height = height * cellSize;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(
image,
crop.x * image.naturalWidth,
crop.y * image.naturalHeight,
crop.width * image.naturalWidth,
crop.height * image.naturalHeight,
0,
0,
canvas.width,
canvas.height,
);
const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
const pixels: Pixel[] = [];
let recognizedCodes = 0;
let colorMatches = 0;
let emptyCells = 0;
const recognitionCache = new Map<string, { code: string; score: number }>();
for (let row = 0; row < height; row++) {
for (let column = 0; column < width; column++) {
const startX = column * cellSize;
const startY = row * cellSize;
const background = dominantCellBackground(data, canvas.width, startX, startY, cellSize);
const backgroundLab = rgbToOklab(background.rgb);
const glyph = extractCellGlyph(data, canvas.width, startX, startY, cellSize, background.rgb);
let recognized: { code: string; score: number } | null = null;
if (glyph) {
let key = "";
for (let index = 0; index < glyph.pixels.length; index++) key += glyph.pixels[index] ? "1" : "0";
recognized = recognitionCache.get(key) ?? recognizeMardCode(glyph, backgroundLab);
recognitionCache.set(key, recognized);
}
if (recognized && recognized.score >= 0.58 && PALETTE_BY_CODE.has(recognized.code)) {
pixels.push({ color: PALETTE_BY_CODE.get(recognized.code)! });
recognizedCodes += 1;
continue;
}
if (missingCodeAsEmpty) {
pixels.push({ color: null });
emptyCells += 1;
continue;
}
const chroma = Math.hypot(backgroundLab[1], backgroundLab[2]);
if (background.alpha < TRANSPARENT_ALPHA_THRESHOLD || (!glyph && backgroundLab[0] > 0.93 && chroma < 0.025)) {
pixels.push({ color: null });
emptyCells += 1;
continue;
}
pixels.push({ color: nearestColor(backgroundLab, PALETTE) });
colorMatches += 1;
}
}
return { pixels, recognizedCodes, colorMatches, emptyCells };
}
function mergePerceptualColors(data: Uint8ClampedArray) { function mergePerceptualColors(data: Uint8ClampedArray) {
const pixelKeys: Array<number | null> = []; const pixelKeys: Array<number | null> = [];
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>(); const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
@@ -755,6 +981,13 @@ export default function Home() {
const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP); const [draftCropRect, setDraftCropRect] = useState<CropRect>(FULL_CROP);
const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free"); const [cropSelectionMode, setCropSelectionMode] = useState<CropSelectionMode>("free");
const [cropDialogOpen, setCropDialogOpen] = useState(false); const [cropDialogOpen, setCropDialogOpen] = useState(false);
const [cropDialogPurpose, setCropDialogPurpose] = useState<CropDialogPurpose>("source");
const [patternImportWidth, setPatternImportWidth] = useState(100);
const [patternImportHeight, setPatternImportHeight] = useState(100);
const [missingCodeAsEmpty, setMissingCodeAsEmpty] = useState(true);
const [patternImportName, setPatternImportName] = useState("");
const [patternImportUrl, setPatternImportUrl] = useState("");
const [patternImportBusy, setPatternImportBusy] = useState(false);
const [pixels, setPixels] = useState<Pixel[]>([]); const [pixels, setPixels] = useState<Pixel[]>([]);
const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set()); const [selectedCodes, setSelectedCodes] = useState<Set<string>>(new Set());
const [onlySelected, setOnlySelected] = useState(false); const [onlySelected, setOnlySelected] = useState(false);
@@ -768,6 +1001,8 @@ export default function Home() {
const [status, setStatus] = useState("示例图已准备好,可以直接转换"); const [status, setStatus] = useState("示例图已准备好,可以直接转换");
const sourceImageRef = useRef<HTMLImageElement | null>(null); const sourceImageRef = useRef<HTMLImageElement | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const patternImportInputRef = useRef<HTMLInputElement | null>(null);
const patternImportImageRef = useRef<HTMLImageElement | null>(null);
const cropCanvasRef = useRef<HTMLCanvasElement | null>(null); const cropCanvasRef = useRef<HTMLCanvasElement | null>(null);
const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null); const cropDialogCanvasRef = useRef<HTMLCanvasElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
@@ -791,6 +1026,9 @@ export default function Home() {
moved: boolean; moved: boolean;
} | null>(null); } | null>(null);
const suppressCanvasClickRef = useRef(false); const suppressCanvasClickRef = useRef(false);
const cropDialogImage = cropDialogPurpose === "pattern-import"
? patternImportImageRef.current
: sourceImageRef.current;
useEffect(() => { useEffect(() => {
logUsage({ event: "page_view" }); logUsage({ event: "page_view" });
@@ -914,7 +1152,7 @@ export default function Home() {
useEffect(() => { useEffect(() => {
if (!cropDialogOpen) return; if (!cropDialogOpen) return;
const canvas = cropDialogCanvasRef.current; const canvas = cropDialogCanvasRef.current;
const image = sourceImageRef.current; const image = cropDialogImage;
if (!canvas || !image) return; if (!canvas || !image) return;
const maximumWidth = 1200; const maximumWidth = 1200;
const maximumHeight = 760; const maximumHeight = 760;
@@ -924,7 +1162,7 @@ export default function Home() {
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height); ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
}, [cropDialogOpen, sourceUrl]); }, [cropDialogOpen, cropDialogPurpose, sourceUrl, patternImportUrl, cropDialogImage]);
useEffect(() => { useEffect(() => {
if (!cropDialogOpen) return; if (!cropDialogOpen) return;
@@ -1107,7 +1345,38 @@ export default function Home() {
image.src = url; image.src = url;
}; };
const handlePatternImportUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
if (!file.type.startsWith("image/")) {
setStatus("请选择 JPG、PNG 或 WebP 图纸图片");
return;
}
const url = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
if (patternImportUrl.startsWith("blob:") && patternImportUrl !== sourceUrl) URL.revokeObjectURL(patternImportUrl);
patternImportImageRef.current = image;
setPatternImportUrl(url);
setPatternImportName(file.name);
setPatternImportWidth(gridWidth);
setPatternImportHeight(gridHeight);
setCropDialogPurpose("pattern-import");
setCropSelectionMode("free");
setDraftCropRect(FULL_CROP);
setCropDialogOpen(true);
setStatus("图纸图片已载入,请框选实际网格并填写行列数");
};
image.onerror = () => {
URL.revokeObjectURL(url);
setStatus("图纸图片读取失败,请换一张图片重试");
};
image.src = url;
};
const openCropDialog = () => { const openCropDialog = () => {
setCropDialogPurpose("source");
setDraftCropRect(cropRect); setDraftCropRect(cropRect);
setCropDialogOpen(true); setCropDialogOpen(true);
}; };
@@ -1128,7 +1397,7 @@ export default function Home() {
let width = Math.abs(endX - startX); let width = Math.abs(endX - startX);
let height = Math.abs(endY - startY); let height = Math.abs(endY - startY);
if (cropSelectionMode === "square") { if (cropSelectionMode === "square") {
const image = sourceImageRef.current; const image = cropDialogImage;
if (!image) return; if (!image) return;
const normalizedSquareRatio = image.naturalHeight / image.naturalWidth; const normalizedSquareRatio = image.naturalHeight / image.naturalWidth;
if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio; if (width / Math.max(height, 0.0001) > normalizedSquareRatio) width = height * normalizedSquareRatio;
@@ -1161,7 +1430,7 @@ export default function Home() {
const chooseCropSelectionMode = (mode: CropSelectionMode) => { const chooseCropSelectionMode = (mode: CropSelectionMode) => {
setCropSelectionMode(mode); setCropSelectionMode(mode);
if (mode !== "square") return; if (mode !== "square") return;
const image = sourceImageRef.current; const image = cropDialogImage;
if (!image) return; if (!image) return;
const currentCenterX = draftCropRect.x + draftCropRect.width / 2; const currentCenterX = draftCropRect.x + draftCropRect.width / 2;
const currentCenterY = draftCropRect.y + draftCropRect.height / 2; const currentCenterY = draftCropRect.y + draftCropRect.height / 2;
@@ -1267,11 +1536,65 @@ export default function Home() {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}; };
const confirmPatternImport = async () => {
const image = patternImportImageRef.current;
const width = Math.max(1, Math.min(256, Math.round(patternImportWidth)));
const height = Math.max(1, Math.min(256, Math.round(patternImportHeight)));
if (!image) {
setStatus("请先选择要导入的图纸图片");
return;
}
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("网格区域太小,请重新框选");
return;
}
setPatternImportBusy(true);
setStatus("正在读取 MARD 编码并匹配格子底色…");
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()));
try {
const result = importMardPatternImage(image, draftCropRect, width, height, missingCodeAsEmpty);
const actualColors = new Set(result.pixels.flatMap(({ color }) => color ? [color.code] : [])).size;
const beadCount = result.pixels.reduce((total, { color }) => total + (color ? 1 : 0), 0);
setPixels(result.pixels);
setGridWidth(width);
setGridHeight(height);
setRequestedWidth(width);
setRequestedHeight(height);
setSelectedCodes(new Set());
setCropRect(draftCropRect);
sourceImageRef.current = image;
setSourceUrl(patternImportUrl);
setSourceName(`导入图纸:${patternImportName}`);
setCropDialogOpen(false);
setStatus(`已导入 ${width} × ${height} · 读取编码 ${result.recognizedCodes} 格 · 底色匹配 ${result.colorMatches} 格 · 空白 ${result.emptyCells}`);
logUsage({
event: "pattern_import",
width,
height,
recognized_codes: result.recognizedCodes,
color_matches: result.colorMatches,
transparent_cells: result.emptyCells,
actual_colors: actualColors,
bead_count: beadCount,
missing_code_as_empty: missingCodeAsEmpty,
});
window.requestAnimationFrame(() => document.getElementById("pattern-preview")?.scrollIntoView({ behavior: "smooth", block: "start" }));
} catch {
setStatus("图纸识别失败,请确认选框与网格行列准确后重试");
} finally {
setPatternImportBusy(false);
}
};
const confirmCrop = () => { const confirmCrop = () => {
if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) { if (draftCropRect.width < 0.03 || draftCropRect.height < 0.03) {
setStatus("框选区域太小,请在大图上拖出更大的选框"); setStatus("框选区域太小,请在大图上拖出更大的选框");
return; return;
} }
if (cropDialogPurpose === "pattern-import") {
void confirmPatternImport();
return;
}
setCropRect(draftCropRect); setCropRect(draftCropRect);
setCropDialogOpen(false); setCropDialogOpen(false);
convertImage(sourceImageRef.current, draftCropRect); convertImage(sourceImageRef.current, draftCropRect);
@@ -1596,6 +1919,8 @@ export default function Home() {
<button className="crop-select-button" onClick={openCropDialog}></button> <button className="crop-select-button" onClick={openCropDialog}></button>
<button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使</button> <button className="reset-crop" onClick={() => { setCropRect(FULL_CROP); setDraftCropRect(FULL_CROP); setStatus("已恢复使用整张图片"); }}>使</button>
<button className="pattern-import-button" onClick={() => patternImportInputRef.current?.click()}></button>
<input ref={patternImportInputRef} className="file-input pattern-import-file" type="file" accept="image/png,image/jpeg,image/webp" onChange={handlePatternImportUpload} />
<div className="field-row"> <div className="field-row">
<label><span></span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label> <label><span></span><input type="number" min="8" max="256" value={requestedWidth} onChange={(e) => setRequestedWidth(Number(e.target.value))} /></label>
@@ -1607,7 +1932,7 @@ export default function Home() {
<label className="field"><span></span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="dominant"></option><option value="smooth"></option></select></label> <label className="field"><span></span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="dominant"></option><option value="smooth"></option></select></label>
<label className="field"><span></span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain"></option><option value="cover"></option></select></label> <label className="field"><span></span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="contain"></option><option value="cover"></option></select></label>
<button className="primary-button" onClick={() => convertImage()}></button> <button className="primary-button" onClick={() => convertImage()}></button>
<p className="privacy-note"></p> <p className="privacy-note"></p>
</aside> </aside>
<section className="pattern-panel" id="pattern-preview"> <section className="pattern-panel" id="pattern-preview">
@@ -1693,15 +2018,26 @@ export default function Home() {
{cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}> {cropDialogOpen && <div className="crop-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.target === event.currentTarget) setCropDialogOpen(false); }}>
<section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title"> <section className="crop-dialog" role="dialog" aria-modal="true" aria-labelledby="crop-dialog-title">
<div className="crop-dialog-header"> <div className="crop-dialog-header">
<div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title"></h2><p></p></div> {cropDialogPurpose === "pattern-import"
? <div><p className="section-kicker">MARD PATTERN IMPORT</p><h2 id="crop-dialog-title"> MARD </h2><p></p></div>
: <div><p className="section-kicker">IMAGE CROP</p><h2 id="crop-dialog-title"></h2><p></p></div>}
<button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button> <button aria-label="关闭取景窗口" onClick={() => setCropDialogOpen(false)}>×</button>
</div> </div>
<div className="crop-mode-bar"> <div className="crop-mode-bar">
<span></span> {cropDialogPurpose === "pattern-import" ? <>
<div className="crop-mode-switch" role="group" aria-label="选框形状"> <div className="pattern-import-dimensions">
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}></button> <label><span></span><input aria-label="导入图纸横向列数" type="number" min="1" max="256" value={patternImportWidth} onChange={(event) => setPatternImportWidth(Number(event.target.value))} /></label>
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}></button> <b>×</b>
</div> <label><span></span><input aria-label="导入图纸纵向行数" type="number" min="1" max="256" value={patternImportHeight} onChange={(event) => setPatternImportHeight(Number(event.target.value))} /></label>
</div>
<label className="pattern-import-empty-option"><input type="checkbox" checked={missingCodeAsEmpty} onChange={(event) => setMissingCodeAsEmpty(event.target.checked)} /> MARD </label>
</> : <>
<span></span>
<div className="crop-mode-switch" role="group" aria-label="选框形状">
<button className={cropSelectionMode === "free" ? "active" : ""} aria-pressed={cropSelectionMode === "free"} onClick={() => chooseCropSelectionMode("free")}></button>
<button className={cropSelectionMode === "square" ? "active" : ""} aria-pressed={cropSelectionMode === "square"} onClick={() => chooseCropSelectionMode("square")}></button>
</div>
</>}
<span className="crop-size-readout">{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span> <span className="crop-size-readout">{Math.round(draftCropRect.width * 100)}% × {Math.round(draftCropRect.height * 100)}%</span>
</div> </div>
<div className="crop-dialog-scroll"> <div className="crop-dialog-scroll">
@@ -1720,6 +2056,11 @@ export default function Home() {
<div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} /> <div className="crop-selection-mask crop-selection-right" style={{ top: `${draftCropRect.y * 100}%`, left: `${(draftCropRect.x + draftCropRect.width) * 100}%`, right: 0, height: `${draftCropRect.height * 100}%` }} />
<div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} /> <div className="crop-selection-mask crop-selection-bottom" style={{ top: `${(draftCropRect.y + draftCropRect.height) * 100}%` }} />
<div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}> <div className="crop-selection-box" style={{ left: `${draftCropRect.x * 100}%`, top: `${draftCropRect.y * 100}%`, width: `${draftCropRect.width * 100}%`, height: `${draftCropRect.height * 100}%` }}>
{cropDialogPurpose === "pattern-import" && patternImportWidth > 0 && patternImportHeight > 0 && <i
className="pattern-import-grid-overlay"
style={{ backgroundSize: `${100 / patternImportWidth}% 100%, 100% ${100 / patternImportHeight}%` }}
aria-hidden="true"
/>}
{(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)} {(["nw", "ne", "sw", "se"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
{cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)} {cropSelectionMode === "free" && (["n", "s", "e", "w"] as CropResizeHandle[]).map((handle) => <i key={handle} className={`crop-resize-handle crop-edge-handle handle-${handle}`} data-crop-handle={handle} aria-hidden="true" />)}
</div> </div>
@@ -1727,9 +2068,13 @@ export default function Home() {
</div> </div>
<div className="crop-dialog-actions"> <div className="crop-dialog-actions">
<button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}></button> <button onClick={() => { setCropSelectionMode("free"); setDraftCropRect(FULL_CROP); }}></button>
<span></span> <span>{cropDialogPurpose === "pattern-import" ? "对齐越准确,格内编码与底色识别越可靠" : "框外拖动可重选,框内拖动可移动,控制点可缩放"}</span>
<button onClick={() => setCropDialogOpen(false)}></button> <button onClick={() => setCropDialogOpen(false)}></button>
<button className="confirm" disabled={draftCropRect.width < 0.03 || draftCropRect.height < 0.03} onClick={confirmCrop}>使</button> <button
className="confirm"
disabled={patternImportBusy || draftCropRect.width < 0.03 || draftCropRect.height < 0.03 || (cropDialogPurpose === "pattern-import" && (patternImportWidth < 1 || patternImportWidth > 256 || patternImportHeight < 1 || patternImportHeight > 256))}
onClick={confirmCrop}
>{cropDialogPurpose === "pattern-import" ? (patternImportBusy ? "正在识别…" : "识别并导入") : "使用此区域"}</button>
</div> </div>
</section> </section>
</div>} </div>}
+3 -3
View File
@@ -11,7 +11,7 @@
"name": "rolldown-runtime" "name": "rolldown-runtime"
}, },
"app/page.tsx": { "app/page.tsx": {
"file": "_next/static/chunks/page-_Oyr6H78.js", "file": "_next/static/chunks/page-CZiDr8AX.js",
"name": "page", "name": "page",
"src": "app/page.tsx", "src": "app/page.tsx",
"isDynamicEntry": true, "isDynamicEntry": true,
@@ -21,7 +21,7 @@
] ]
}, },
"node_modules/vinext/dist/shims/layout-segment-context.js": { "node_modules/vinext/dist/shims/layout-segment-context.js": {
"file": "_next/static/chunks/layout-segment-context-T1W3MOgS.js", "file": "_next/static/chunks/layout-segment-context-BKsVwj6c.js",
"name": "layout-segment-context", "name": "layout-segment-context",
"src": "node_modules/vinext/dist/shims/layout-segment-context.js", "src": "node_modules/vinext/dist/shims/layout-segment-context.js",
"isDynamicEntry": true, "isDynamicEntry": true,
@@ -32,7 +32,7 @@
] ]
}, },
"virtual:vinext-app-browser-entry": { "virtual:vinext-app-browser-entry": {
"file": "_next/static/chunks/index-DZQMDmd9.js", "file": "_next/static/chunks/index-CRBC3ESD.js",
"name": "index", "name": "index",
"src": "virtual:vinext-app-browser-entry", "src": "virtual:vinext-app-browser-entry",
"isEntry": true, "isEntry": true,
+2 -2
View File
@@ -1,2 +1,2 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/index.DYq6BwQf.css" data-rsc-css-href="/_next/static/css/index.DYq6BwQf.css" data-precedence="vite-rsc/importer-resources"/><link rel="modulepreload" fetchPriority="low" href="/_next/static/chunks/index-DZQMDmd9.js"/><script src="/_next/static/chunks/rolldown-runtime-C60lm6uB.js" type="module" async=""></script><script src="/_next/static/chunks/framework-BgSIrAUN.js" type="module" async=""></script><meta name="robots" content="noindex"/><title>豆格工坊|图片转拼豆图纸</title><meta name="description" content="在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"/><title>404: This page could not be found.</title><script>Object.assign(((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}),{params:{},nav:{"pathname":"/__vinext_nonexistent_for_404__","searchParams":[]}})</script><link rel="modulepreload" href="/_next/static/chunks/index-DZQMDmd9.js" /> <!DOCTYPE html><html lang="zh-CN"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><link rel="stylesheet" href="/_next/static/css/index.B4qFKCfT.css" data-rsc-css-href="/_next/static/css/index.B4qFKCfT.css" data-precedence="vite-rsc/importer-resources"/><link rel="modulepreload" fetchPriority="low" href="/_next/static/chunks/index-CRBC3ESD.js"/><script src="/_next/static/chunks/rolldown-runtime-C60lm6uB.js" type="module" async=""></script><script src="/_next/static/chunks/framework-BgSIrAUN.js" type="module" async=""></script><meta name="robots" content="noindex"/><title>豆格工坊|图片转拼豆图纸</title><meta name="description" content="在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"/><title>404: This page could not be found.</title><script>Object.assign(((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}),{params:{},nav:{"pathname":"/__vinext_nonexistent_for_404__","searchParams":[]}})</script><link rel="modulepreload" href="/_next/static/chunks/index-CRBC3ESD.js" />
</head><body><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script type="module" src="/_next/static/chunks/index-DZQMDmd9.js" id="_R_" async=""></script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("1:\"$Sreact.fragment\"\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push(":HL[\"/_next/static/css/index.DYq6BwQf.css\",\"style\" ]\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("0:{\"__route\":\"route:/__vinext_nonexistent_for_404__\",\"__interceptionContext\":null,\"__layoutIds\":[],\"__rootLayout\":null,\"route:/__vinext_nonexistent_for_404__\":[[[\"$\",\"link\",\"css:/_next/static/css/index.DYq6BwQf.css\",{\"rel\":\"stylesheet\",\"precedence\":\"vite-rsc/importer-resources\",\"href\":\"/_next/static/css/index.DYq6BwQf.css\",\"data-rsc-css-href\":\"/_next/static/css/index.DYq6BwQf.css\"}],\"$undefined\"],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"children\":[[\"$\",\"meta\",\"charset\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"robots\",{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$1\",\"metadata\",{\"children\":[[\"$\",\"title\",\"0\",{\"children\":\"豆格工坊|图片转拼豆图纸\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。\"}]]}],[\"$\",\"$1\",\"viewport\",{\"children\":[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]}],[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]]]}]}]]}\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).done=true</script></body></html> </head><body><div style="font-family:system-ui,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center"><div><style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style><h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1><div style="display:inline-block"><h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2></div></div></div><script type="module" src="/_next/static/chunks/index-CRBC3ESD.js" id="_R_" async=""></script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("1:\"$Sreact.fragment\"\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push(":HL[\"/_next/static/css/index.B4qFKCfT.css\",\"style\" ]\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).rsc.push("0:{\"__route\":\"route:/__vinext_nonexistent_for_404__\",\"__interceptionContext\":null,\"__layoutIds\":[],\"__rootLayout\":null,\"route:/__vinext_nonexistent_for_404__\":[[[\"$\",\"link\",\"css:/_next/static/css/index.B4qFKCfT.css\",{\"rel\":\"stylesheet\",\"precedence\":\"vite-rsc/importer-resources\",\"href\":\"/_next/static/css/index.B4qFKCfT.css\",\"data-rsc-css-href\":\"/_next/static/css/index.B4qFKCfT.css\"}],\"$undefined\"],[\"$\",\"html\",null,{\"lang\":\"zh-CN\",\"children\":[\"$\",\"body\",null,{\"children\":[[\"$\",\"meta\",\"charset\",{\"charSet\":\"utf-8\"}],[\"$\",\"meta\",\"robots\",{\"name\":\"robots\",\"content\":\"noindex\"}],[\"$\",\"$1\",\"metadata\",{\"children\":[[\"$\",\"title\",\"0\",{\"children\":\"豆格工坊|图片转拼豆图纸\"}],[\"$\",\"meta\",\"1\",{\"name\":\"description\",\"content\":\"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。\"}]]}],[\"$\",\"$1\",\"viewport\",{\"children\":[[\"$\",\"meta\",\"0\",{\"name\":\"viewport\",\"content\":\"width=device-width, initial-scale=1\"}]]}],[[\"$\",\"title\",null,{\"children\":\"404: This page could not be found.\"}],[\"$\",\"div\",null,{\"style\":{\"fontFamily\":\"system-ui,\\\"Segoe UI\\\",Roboto,Helvetica,Arial,sans-serif,\\\"Apple Color Emoji\\\",\\\"Segoe UI Emoji\\\"\",\"height\":\"100vh\",\"textAlign\":\"center\",\"display\":\"flex\",\"flexDirection\":\"column\",\"alignItems\":\"center\",\"justifyContent\":\"center\"},\"children\":[\"$\",\"div\",null,{\"children\":[[\"$\",\"style\",null,{\"dangerouslySetInnerHTML\":{\"__html\":\"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}\"}}],[\"$\",\"h1\",null,{\"className\":\"next-error-h1\",\"style\":{\"display\":\"inline-block\",\"margin\":\"0 20px 0 0\",\"padding\":\"0 23px 0 0\",\"fontSize\":24,\"fontWeight\":500,\"verticalAlign\":\"top\",\"lineHeight\":\"49px\"},\"children\":404}],[\"$\",\"div\",null,{\"style\":{\"display\":\"inline-block\"},\"children\":[\"$\",\"h2\",null,{\"style\":{\"fontSize\":14,\"fontWeight\":400,\"lineHeight\":\"49px\",\"margin\":0},\"children\":\"This page could not be found.\"}]}]]}]}]]]}]}]]}\n")</script><script>((self[Symbol.for("vinext.navigationRuntime")]??={bootstrap:{routeManifest:null},functions:{}}).bootstrap.rsc??={rsc:[]}).done=true</script></body></html>
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{r as e}from"./rolldown-runtime-C60lm6uB.js";import{r as t}from"./framework-BgSIrAUN.js";import{t as n}from"./index-DZQMDmd9.js";var r=e(t(),1),i=new Map;function a(e,t){return e?{...e,...t}:t}function o({providerId:e,segmentMap:t,children:o}){let s=(0,r.useRef)(null),c=n(),l=a(s.current??(e?i.get(e)??null:null),t);return(0,r.useEffect)(()=>{s.current=l,e&&i.set(e,l)},[l,e]),c?(0,r.createElement)(c.Provider,{value:l},o):o}export{o as LayoutSegmentProvider,a as mergeLayoutSegmentMap}; import{r as e}from"./rolldown-runtime-C60lm6uB.js";import{r as t}from"./framework-BgSIrAUN.js";import{t as n}from"./index-CRBC3ESD.js";var r=e(t(),1),i=new Map;function a(e,t){return e?{...e,...t}:t}function o({providerId:e,segmentMap:t,children:o}){let s=(0,r.useRef)(null),c=n(),l=a(s.current??(e?i.get(e)??null:null),t);return(0,r.useEffect)(()=>{s.current=l,e&&i.set(e,l)},[l,e]),c?(0,r.createElement)(c.Provider,{value:l},o):o}export{o as LayoutSegmentProvider,a as mergeLayoutSegmentMap};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
7:I["8c0f216c4604",[],"Slot",1] 7:I["8c0f216c4604",[],"Slot",1]
8:I["9276801271d6",[],"AppRouterScrollTarget",1] 8:I["9276801271d6",[],"AppRouterScrollTarget",1]
9:I["593f344dc510",[],"RedirectBoundary",1] 9:I["593f344dc510",[],"RedirectBoundary",1]
:HL["/_next/static/css/index.DYq6BwQf.css","style" ] :HL["/_next/static/css/index.B4qFKCfT.css","style" ]
0:{"__route":"route:/","__interceptionContext":null,"__layoutIds":["layout:/"],"__rootLayout":"/","__sourcePage":"/page","page:/":"$L1","layout:/":[[[["$","link","css:/_next/static/css/index.DYq6BwQf.css",{"rel":"stylesheet","precedence":"vite-rsc/importer-resources","href":"/_next/static/css/index.DYq6BwQf.css","data-rsc-css-href":"/_next/static/css/index.DYq6BwQf.css"}],"$undefined"],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"children":["$","$L2",null,{}]}]}]],null],"route:/":[[["$","meta",null,{"charSet":"utf-8"}],[["$","title","0",{"children":"豆格工坊|图片转拼豆图纸"}],["$","meta","1",{"name":"description","content":"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"}]],[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]],["$","$L3",null,{"fallback":"$4","children":["$","$L5",null,{"fallback":"$4","children":["$","$L6",null,{"providerId":"layout:/","segmentMap":{"children":[]},"children":["$","$L7",null,{"id":"layout:/","parallelSlots":"$undefined","children":["$","$L8",null,{"children":["$","$L9",null,{"children":[["$","$L6",null,{"providerId":"page:/","segmentMap":{"children":["__PAGE__"]},"children":["$","$L7",null,{"id":"page:/"}]}],null]}]}]}]}]}]}],null,null],"__layoutFlags":{"layout:/":"s"},"__artifactCompatibility":{"schemaVersion":1,"graphVersion":"app-route-graph:1177fd80f83fa7b0","deploymentVersion":"38181f96-3d96-4263-aa86-5802d2c534fc","appElementsSchemaVersion":1,"rscPayloadSchemaVersion":1,"rootBoundaryId":"/","renderEpoch":null},"__renderObservation":{"schemaVersion":1,"output":{"kind":"app-rsc","mountedSlotsFingerprint":null,"renderEpoch":null,"rootBoundaryId":"/","routeId":"route:/"},"completeness":"partial","boundaryOutcome":{"kind":"unknown"},"requestApis":[{"kind":"connection","status":"unknown"},{"kind":"cookies","status":"unknown"},{"kind":"draftMode","status":"unknown"},{"kind":"headers","status":"unknown"},{"kind":"params","status":"unknown"},{"kind":"searchParams","status":"unknown"}],"dynamicFetches":[],"cacheTags":["/","_N_T_/","_N_T_/index","_N_T_/layout","_N_T_/page"],"pathTags":["/"],"cacheability":"unknown","downgrade":{"target":"freshRender","reasons":[{"code":"CP_DOWNGRADE_CACHEABILITY_UNKNOWN","target":"freshRender"},{"code":"CP_DOWNGRADE_INCOMPLETE_OBSERVATION","completeness":"partial","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"connection","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"cookies","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"draftMode","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"headers","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"params","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"searchParams","target":"freshRender"}],"fallback":{"kind":"breakerFallback","code":"CP_PRIVATE_DYNAMIC_DOWNGRADE","mode":"renderFresh","scope":"affectedOutput","fields":{"reasonCodes":["CP_DOWNGRADE_CACHEABILITY_UNKNOWN","CP_DOWNGRADE_INCOMPLETE_OBSERVATION","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API"],"target":"freshRender"}},"isPublicCacheCandidate":false}}} 0:{"__route":"route:/","__interceptionContext":null,"__layoutIds":["layout:/"],"__rootLayout":"/","__sourcePage":"/page","page:/":"$L1","layout:/":[[[["$","link","css:/_next/static/css/index.B4qFKCfT.css",{"rel":"stylesheet","precedence":"vite-rsc/importer-resources","href":"/_next/static/css/index.B4qFKCfT.css","data-rsc-css-href":"/_next/static/css/index.B4qFKCfT.css"}],"$undefined"],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"children":["$","$L2",null,{}]}]}]],null],"route:/":[[["$","meta",null,{"charSet":"utf-8"}],[["$","title","0",{"children":"豆格工坊|图片转拼豆图纸"}],["$","meta","1",{"name":"description","content":"在浏览器中把图片转换为拼豆像素图纸,按色号、名称或像素格筛选颜色。"}]],[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]],["$","$L3",null,{"fallback":"$4","children":["$","$L5",null,{"fallback":"$4","children":["$","$L6",null,{"providerId":"layout:/","segmentMap":{"children":[]},"children":["$","$L7",null,{"id":"layout:/","parallelSlots":"$undefined","children":["$","$L8",null,{"children":["$","$L9",null,{"children":[["$","$L6",null,{"providerId":"page:/","segmentMap":{"children":["__PAGE__"]},"children":["$","$L7",null,{"id":"page:/"}]}],null]}]}]}]}]}]}],null,null],"__layoutFlags":{"layout:/":"s"},"__artifactCompatibility":{"schemaVersion":1,"graphVersion":"app-route-graph:1177fd80f83fa7b0","deploymentVersion":"9950d562-b31f-4727-8327-2dd57c54da0f","appElementsSchemaVersion":1,"rscPayloadSchemaVersion":1,"rootBoundaryId":"/","renderEpoch":null},"__renderObservation":{"schemaVersion":1,"output":{"kind":"app-rsc","mountedSlotsFingerprint":null,"renderEpoch":null,"rootBoundaryId":"/","routeId":"route:/"},"completeness":"partial","boundaryOutcome":{"kind":"unknown"},"requestApis":[{"kind":"connection","status":"unknown"},{"kind":"cookies","status":"unknown"},{"kind":"draftMode","status":"unknown"},{"kind":"headers","status":"unknown"},{"kind":"params","status":"unknown"},{"kind":"searchParams","status":"unknown"}],"dynamicFetches":[],"cacheTags":["/","_N_T_/","_N_T_/index","_N_T_/layout","_N_T_/page"],"pathTags":["/"],"cacheability":"unknown","downgrade":{"target":"freshRender","reasons":[{"code":"CP_DOWNGRADE_CACHEABILITY_UNKNOWN","target":"freshRender"},{"code":"CP_DOWNGRADE_INCOMPLETE_OBSERVATION","completeness":"partial","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"connection","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"cookies","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"draftMode","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"headers","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"params","target":"freshRender"},{"code":"CP_DOWNGRADE_UNKNOWN_REQUEST_API","requestApi":"searchParams","target":"freshRender"}],"fallback":{"kind":"breakerFallback","code":"CP_PRIVATE_DYNAMIC_DOWNGRADE","mode":"renderFresh","scope":"affectedOutput","fields":{"reasonCodes":["CP_DOWNGRADE_CACHEABILITY_UNKNOWN","CP_DOWNGRADE_INCOMPLETE_OBSERVATION","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API","CP_DOWNGRADE_UNKNOWN_REQUEST_API"],"target":"freshRender"}},"isPublicCacheCandidate":false}}}
a:I["6efdf509a785",[],"default",1] a:I["6efdf509a785",[],"default",1]
1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}] 1:["$","$La",null,{"params":"$@b","searchParams":"$@c"}]
b:{} b:{}
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"appBrowserEntry": "_next/static/chunks/index-DZQMDmd9.js" "appBrowserEntry": "_next/static/chunks/index-CRBC3ESD.js"
} }
+24 -68
View File
@@ -1,91 +1,47 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { access, readFile, readdir } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import test from "node:test"; import test from "node:test";
const developmentPreviewMeta =
/<meta(?=[^>]*\bname=["']codex-preview["'])(?=[^>]*\bcontent=["']development["'])[^>]*>/i;
const templateRoot = new URL("../", import.meta.url);
const previewRoot = new URL("../app/_sites-preview/", import.meta.url);
async function render() { async function render() {
const workerUrl = new URL("../dist/server/index.js", import.meta.url); const workerUrl = new URL("../dist/server/index.js", import.meta.url);
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`); workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
const { default: worker } = await import(workerUrl.href); const { default: worker } = await import(workerUrl.href);
return worker.fetch( return worker.fetch(
new Request("http://localhost/", { new Request("http://localhost/", { headers: { accept: "text/html" } }),
headers: { accept: "text/html" }, { ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } },
}), { waitUntil() {}, passThroughOnException() {} },
{
ASSETS: {
fetch: async () => new Response("Not found", { status: 404 }),
},
},
{
waitUntil() {},
passThroughOnException() {},
},
); );
} }
test("server-renders the starter loading skeleton", async () => { test("server-renders the pixel bead application shell", async () => {
const response = await render(); const response = await render();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i); assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
const html = await response.text(); const html = await response.text();
assert.match(html, developmentPreviewMeta); assert.match(html, /<title>豆格工坊|图片转拼豆图纸<\/title>/i);
assert.match(html, /<title>Your site is taking shape<\/title>/i); assert.match(html, /导入现成图纸/);
assert.match(html, /Building your site/); assert.match(html, /MARD 221 基础色卡/);
assert.match(html, /Your site is taking shape/); assert.match(html, /原图与现成图纸都只在当前浏览器中处理/);
assert.match( assert.doesNotMatch(html, /codex-preview|Your site is taking shape/i);
html,
/Your first version will appear here automatically when its ready\./,
);
assert.doesNotMatch(html, /Codex/);
assert.match(html, /react-loading-skeleton/);
assert.match(html, /role="status"/);
}); });
test("keeps the loading skeleton scoped and disposable", async () => { test("keeps MARD pattern import wired to the crop and recognition flow", async () => {
const [preview, css, page, layout, packageJson, files] = await Promise.all([ const [page, css, palette] = await Promise.all([
readFile(new URL("SkeletonPreview.tsx", previewRoot), "utf8"),
readFile(new URL("preview.css", previewRoot), "utf8"),
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"), readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
readFile(new URL("../app/layout.tsx", import.meta.url), "utf8"), readFile(new URL("../app/globals.css", import.meta.url), "utf8"),
readFile(new URL("../package.json", import.meta.url), "utf8"), readFile(new URL("../app/mard-palette.ts", import.meta.url), "utf8"),
readdir(previewRoot),
]); ]);
assert.deepEqual(files.sort(), ["SkeletonPreview.tsx", "preview.css"]); assert.match(page, /function importMardPatternImage\(/);
assert.match(preview, /from "react-loading-skeleton"/); assert.match(page, /recognizedCodes/);
assert.match(preview, /baseColor="#eceae7"/); assert.match(page, /missingCodeAsEmpty/);
assert.match(preview, /highlightColor="#f9f8f6"/); assert.match(page, /没有识别到 MARD 编码的格子视为空白/);
assert.match(preview, /duration=\{2\.8\}/); assert.match(page, /pattern-import-grid-overlay/);
assert.match(preview, /sites-skeleton-search-placeholder/); assert.match(page, /max="256"/);
assert.match(packageJson, /"react-loading-skeleton": "3\.5\.0"/); assert.match(css, /\.pattern-import-grid-overlay/);
assert.match(css, /@media \(max-width: 760px\)/);
const shellIndex = preview.indexOf('className="sites-skeleton-shell"'); assert.match(palette, /export const MARD_221/);
const statusIndex = preview.indexOf('className="sites-skeleton-status"'); assert.match(palette, /A:\s*"#[0-9A-F]{6}/);
assert.ok(shellIndex >= 0 && statusIndex > shellIndex);
assert.match(css, /position:\s*fixed/);
assert.match(css, /inset:\s*0/);
assert.match(css, /opacity:\s*0\.52/);
assert.match(css, /prefers-reduced-motion:\s*reduce/);
assert.doesNotMatch(css, /#020617|canvas|pets|progress/i);
assert.doesNotMatch(
preview,
/loading-spinner|status-mark|status-progress|canvas|cookie|random/i,
);
assert.match(page, /export const metadata:\s*Metadata/);
assert.match(page, /"codex-preview": "development"/);
assert.match(page, /<SkeletonPreview \/>/);
assert.match(layout, /title:\s*"Starter Project"/);
assert.doesNotMatch(layout, /codex-preview|_sites-preview|themeColor|\bViewport\b/);
assert.doesNotMatch(css, /(^|\s)(html|body)\s*\{/m);
await assert.rejects(
access(new URL("public/_sites-preview", templateRoot)),
);
}); });