style: 저장소 전체 포맷터 일괄 적용 (prettier·biome·ruff)

파일마다 포맷 폭이 달라(≈80 대 100) 한 줄만 고쳐도 포맷터가 무관한 줄을 대량
재포맷했음. 사용자 지시로 전체를 한 번에 맞춤. 코드 동작 변경 없음 — 포맷만.

- 프론트엔드 `.ts/.css/.html` → 저장소 prettier (`.prettierrc`, printWidth 100)
- `B07_DesignDetail/openwebcad/**` → 자체 biome (tab 들여쓰기·single quote·lineWidth 100).
  `biome format` 만 사용 — `biome lint --write` 는 포맷 아닌 코드 수정까지 하므로 제외
- 파이썬 → `ruff format` (엔진 코드는 이미 정합, resources·scratch 스크립트 24개만 변경)

두 포맷터가 서로 되돌리지 않도록 `.prettierignore` 신규 — openwebcad 와 빌드·산출물
폴더를 prettier 대상에서 뺌. `.prettierrc` 에 `endOfLine: "auto"` 추가 — 기본값 `lf` 가
`core.autocrlf=true` 로 받은 CRLF 파일을 매번 전부 다시 써서 `--list-different` 가
실제 포맷 차이를 가리고 있었음.

검증: `tsc --noEmit` 통과(루트·openwebcad 둘 다), pytest 349 passed / 17 skipped /
0 failed, CAD vitest 87건 중 81 passed / 6 failed(laptop-sub 기준선과 동일, 회귀 없음).
포맷터 재실행 시 prettier·biome 모두 변경 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-02 07:08:24 +09:00
co-authored by Claude Opus 5
parent 43f23c38ef
commit 4cb9b15939
143 changed files with 4133 additions and 4103 deletions
+10
View File
@@ -0,0 +1,10 @@
# CAD 앱은 자체 포맷터(biome, tab 들여쓰기·single quote)를 쓴다 — prettier 가 덮으면
# 두 포맷터가 서로 되돌리며 매 커밋이 통째로 재포맷된다. 그 폴더는 `npx biome format` 몫.
B07_DesignDetail/openwebcad/
# 빌드·산출물·가상환경 — 포맷 대상이 아니다.
dist/
venv/
storage/
tmp/
graphify-out/
+14 -36
View File
@@ -69,15 +69,12 @@ export async function uploadProjectFiles(
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/files`,
{
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/files`, {
method: "POST",
credentials: "include",
body: formData,
signal: controller.signal,
},
);
});
return await readJsonOrThrow<FileUploadResponse>(response);
} finally {
window.clearTimeout(timeoutId);
@@ -92,9 +89,7 @@ export async function createUploadSession(
completeUpload = false,
lasFree = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-sessions`,
{
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -106,8 +101,7 @@ export async function createUploadSession(
complete_upload: completeUpload,
las_free: lasFree,
}),
},
);
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
}
@@ -138,9 +132,7 @@ export async function finalizeUploadSession(
fingerprint?: string | null,
lasFree = false,
): Promise<FileUploadResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/finalize`,
{
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -151,8 +143,7 @@ export async function finalizeUploadSession(
fingerprint: fingerprint ?? null,
las_free: lasFree,
}),
},
);
});
return await readJsonOrThrow<FileUploadResponse>(response);
}
@@ -160,13 +151,10 @@ export async function fetchUploadStatus(
projectId: string,
sessionId: string,
): Promise<UploadStatusResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`,
{
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-status/${sessionId}`, {
method: "GET",
credentials: "include",
},
);
});
return await readJsonOrThrow<UploadStatusResponse>(response);
}
@@ -200,16 +188,11 @@ export interface UploadOverviewResponse {
}
/** 재접속 시 업로드 현황(정본) — 완료 파일 목록 + 중단 세션 + 완료 여부. */
export async function fetchUploadOverview(
projectId: string,
): Promise<UploadOverviewResponse> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/upload-overview`,
{
export async function fetchUploadOverview(projectId: string): Promise<UploadOverviewResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-overview`, {
method: "GET",
credentials: "include",
},
);
});
return await readJsonOrThrow<UploadOverviewResponse>(response);
}
@@ -223,15 +206,10 @@ export interface WF1AnalysisStatus {
error?: string;
}
export async function checkWF1AnalysisStatus(
projectId: string,
): Promise<WF1AnalysisStatus> {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/surface/status`,
{
export async function checkWF1AnalysisStatus(projectId: string): Promise<WF1AnalysisStatus> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/status`, {
method: "GET",
credentials: "include",
},
);
});
return await readJsonOrThrow<WF1AnalysisStatus>(response);
}
+42 -126
View File
@@ -9,14 +9,8 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createButton, createTag, showToast } from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createWorkflowOverlays } from "@ui/ui_template_overlay";
import {
createStepBar,
WORKFLOW_STEP_ICONS,
} from "@ui/ui_template_workflow_layout";
import {
fetchUploadOverview,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import { createStepBar, WORKFLOW_STEP_ICONS } from "@ui/ui_template_workflow_layout";
import { fetchUploadOverview, type UploadedFileResult } from "./B03_FileInput_Api_Fetch";
import { clearPreloadMark } from "../A00_Common/b_asset_cache";
import { navigateTo } from "../A00_Common/router";
import { attachTempBatch } from "../B01_Dashboard/B01_Dashboard_Api_Temp";
@@ -129,10 +123,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return Array.from(slots.values()).filter((state) => state.file);
}
function setCardState(
slot: FileSlot,
stateName: "empty" | "selected" | UploadStatus,
): void {
function setCardState(slot: FileSlot, stateName: "empty" | "selected" | UploadStatus): void {
const card = cardMap.get(slot);
if (!card) return;
card.classList.remove(
@@ -146,9 +137,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const cssState = stateName === "failed" ? "error" : stateName;
card.classList.add(`b03-file__card--${cssState}`);
const badgeContainer = card.querySelector<HTMLDivElement>(
".b03-file__card-badge-container",
);
const badgeContainer = card.querySelector<HTMLDivElement>(".b03-file__card-badge-container");
if (badgeContainer) {
badgeContainer.replaceChildren();
if (stateName === "empty") {
@@ -214,38 +203,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!state || !card) return;
renderExtensionLabel(card, state);
const fileName = card.querySelector<HTMLSpanElement>(
".b03-file__file-name",
);
const fileSize = card.querySelector<HTMLSpanElement>(
".b03-file__file-size",
);
const progress = card.querySelector<HTMLDivElement>(
".b03-file__progress-bar",
);
const progressBytes = card.querySelector<HTMLSpanElement>(
".b03-file__progress-bytes",
);
const progressSpeed = card.querySelector<HTMLSpanElement>(
".b03-file__progress-speed",
);
const progressEta = card.querySelector<HTMLSpanElement>(
".b03-file__progress-eta",
);
const error = card.querySelector<HTMLDivElement>(
".b03-file__error-message",
);
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
);
const fileName = card.querySelector<HTMLSpanElement>(".b03-file__file-name");
const fileSize = card.querySelector<HTMLSpanElement>(".b03-file__file-size");
const progress = card.querySelector<HTMLDivElement>(".b03-file__progress-bar");
const progressBytes = card.querySelector<HTMLSpanElement>(".b03-file__progress-bytes");
const progressSpeed = card.querySelector<HTMLSpanElement>(".b03-file__progress-speed");
const progressEta = card.querySelector<HTMLSpanElement>(".b03-file__progress-eta");
const error = card.querySelector<HTMLDivElement>(".b03-file__error-message");
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove");
const percent = state.file
? Math.min(100, (state.progressBytes / state.file.size) * 100)
: 0;
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName)
fileName.textContent =
state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileSize) {
fileSize.textContent = state.file
? formatBytes(state.file.size)
@@ -271,13 +240,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (remove) remove.hidden = !state.file;
if (state.error) setCardState(slot, "failed");
else if (!state.file)
setCardState(slot, state.serverUploaded ? "completed" : "empty");
else
setCardState(
slot,
state.uploadStatus === "pending" ? "selected" : state.uploadStatus,
);
else if (!state.file) setCardState(slot, state.serverUploaded ? "completed" : "empty");
else setCardState(slot, state.uploadStatus === "pending" ? "selected" : state.uploadStatus);
updateUploadButton();
}
@@ -289,23 +253,15 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
renderSlot(slot);
}
function validateFileForSlot(
file: File,
state: FileSlotState,
): string | null {
function validateFileForSlot(file: File, state: FileSlotState): string | null {
const extension = getExtension(file.name);
const maxBytes = UPLOAD_MAX_MB * 1024 * 1024;
if (!state.extensions.includes(extension))
return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes)
return L("B03_File_Error_Size");
if (!state.extensions.includes(extension)) return L("B03_File_Error_SlotType");
if (file.size === 0 || file.size > maxBytes) return L("B03_File_Error_Size");
return null;
}
async function assignFileToSlot(
file: File,
targetSlot?: FileSlot,
): Promise<void> {
async function assignFileToSlot(file: File, targetSlot?: FileSlot): Promise<void> {
const state = targetSlot ? slots.get(targetSlot) : undefined;
if (!state) {
pageError.textContent = `${L("B03_File_Error_Extension")} ${file.name}`;
@@ -317,19 +273,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
return;
}
if (!targetSlot && state.file && state.file.name !== file.name) {
showErrorMessage(
state.slot,
`${L("B03_File_Error_DuplicateSlot")} ${file.name}`,
);
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
return;
}
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
// 같은 파일을 다시 고르는 경우는 업로드가 미완료라 serverUploaded가 없어 묻지 않는다.
if (state.serverUploaded) {
const accepted = await confirmReplaceUpload(
L(state.labelKey),
state.serverUploaded.name,
);
const accepted = await confirmReplaceUpload(L(state.labelKey), state.serverUploaded.name);
if (!accepted) return;
}
@@ -350,18 +300,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
updateUploadButton();
}
function onFileSelected(
selection: readonly File[],
targetSlot?: FileSlot,
): void {
function onFileSelected(selection: readonly File[], targetSlot?: FileSlot): void {
if (selection.length === 0) return;
// LAS 없이 설계를 켜면 포인트클라우드는 아예 받지 않는다 (2026-08-30 사용자 지시) —
// 카드를 회색으로 덮어도 파일 선택 영역·드롭으로 들어올 수 있어 여기서 걸러 낸다.
const pointCloudExtensions = slots.get("las_laz")?.extensions ?? [];
const files = lasFreeDesign
? selection.filter(
(file) => !pointCloudExtensions.includes(getExtension(file.name)),
)
? selection.filter((file) => !pointCloudExtensions.includes(getExtension(file.name)))
: selection;
const blocked = files.length !== selection.length;
if (blocked && files.length === 0) {
@@ -433,8 +378,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
* CSV 한 장으로 넣는 흐름을 막으면 안 된다(2026-08-31).
*/
function isSlotRequired(state: FileSlotState): boolean {
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot))
return routeIsShapefile();
if (SHAPEFILE_DEPENDENT_SLOTS.includes(state.slot)) return routeIsShapefile();
if (state.slot === "las_laz") return !lasFreeDesign;
return state.isRequired;
}
@@ -447,14 +391,12 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
// 필수 슬롯은 로컬 선택이 없어도 **서버에 이미 올라간 파일**이 있으면 충족이다 —
// 재접속 후 파일 하나만 교체 업로드하는 흐름을 막지 않는다(2026-08-04 사용자 지시).
const missingRequired = Array.from(slots.values()).some(
(state) =>
isSlotRequired(state) && !state.file && !state.serverUploaded,
(state) => isSlotRequired(state) && !state.file && !state.serverUploaded,
);
if (missingRequired) return L("B03_File_Error_RequiredSlots");
if (!lasFreeDesign) {
const lasState = slots.get("las_laz");
if (!lasState?.file && !lasState?.serverUploaded)
return L("B03_File_Error_Las");
if (!lasState?.file && !lasState?.serverUploaded) return L("B03_File_Error_Las");
}
for (const state of selected) {
if (state.error) return state.error;
@@ -490,8 +432,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
: "prj"
: Array.from(slots.values()).find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
)?.slot;
const state = slot ? slots.get(slot) : undefined;
if (state) {
@@ -533,26 +474,18 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!card) throw new Error("file-card-template is invalid");
card.dataset.slotId = state.slot;
card.querySelector(".b03-file__card-icon")!.textContent = state.icon;
card.querySelector(".b03-file__card-label")!.textContent = L(
state.labelKey,
);
card.querySelector(".b03-file__card-label")!.textContent = L(state.labelKey);
renderExtensionLabel(card, state);
const input = card.querySelector<HTMLInputElement>(
".b03-file__slot-input",
)!;
const input = card.querySelector<HTMLInputElement>(".b03-file__slot-input")!;
input.accept = state.extensions.join(",");
const select = card.querySelector<HTMLButtonElement>(
".b03-file__card-select",
)!;
const select = card.querySelector<HTMLButtonElement>(".b03-file__card-select")!;
select.textContent = L("B03_File_Card_Select");
select.addEventListener("click", () => input.click());
input.addEventListener("change", () => {
onFileSelected(input.files ? Array.from(input.files) : [], state.slot);
input.value = "";
});
const remove = card.querySelector<HTMLButtonElement>(
".b03-file__card-remove",
)!;
const remove = card.querySelector<HTMLButtonElement>(".b03-file__card-remove")!;
remove.textContent = "×";
remove.title = L("B03_File_Card_Remove");
remove.setAttribute("aria-label", L("B03_File_Card_Remove"));
@@ -599,9 +532,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
if (!activeProjectId) return;
for (const state of selectedStates()) {
const stored = localStorage.getItem(
makeSessionKey(activeProjectId, state.file!),
);
const stored = localStorage.getItem(makeSessionKey(activeProjectId, state.file!));
if (!stored) continue;
const session = JSON.parse(stored) as StoredUploadSession;
state.uploadSessionId = session.uploadSessionId;
@@ -671,8 +602,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const detail =
error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
const detail = error instanceof Error ? error.message : L("B03_Temp_Attach_Failed");
pageError.textContent = `${L("B03_Temp_Attach_Failed")} ${detail}`;
showToast(L("B03_Temp_Attach_Failed"), "error");
}
@@ -701,9 +631,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
}
}
async function startChunkedUpload(
targetStates = selectedStates(),
): Promise<void> {
async function startChunkedUpload(targetStates = selectedStates()): Promise<void> {
if (isUploading) return;
// 보관함에서 불러온 자료가 지정돼 있으면 그것을 옮기는 것이 이 버튼의 동작이다.
if (tempPicker.selected()) {
@@ -751,11 +679,8 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showToast(L("B03_File_Analysis_StillRunning"), "warning");
}
} catch (error) {
const failed = targetStates.find(
(state) => state.uploadStatus === "uploading",
);
const detail =
error instanceof Error ? error.message : L("B03_File_Upload_Failed");
const failed = targetStates.find((state) => state.uploadStatus === "uploading");
const detail = error instanceof Error ? error.message : L("B03_File_Upload_Failed");
if (failed) showErrorMessage(failed.slot, detail);
pageError.textContent = `${L("B03_File_Upload_Failed")} ${detail}`;
showToast(L("B03_File_Upload_Failed"), "error");
@@ -789,9 +714,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
function onB03_File_Drop(event: DragEvent): void {
event.preventDefault();
dropzone.classList.remove("is-dragging");
onFileSelected(
event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : [],
);
onFileSelected(event.dataTransfer?.files ? Array.from(event.dataTransfer.files) : []);
}
fileInput.addEventListener("change", onB03_File_Select_Change);
@@ -803,9 +726,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
event.preventDefault();
dropzone.classList.add("is-dragging");
});
dropzone.addEventListener("dragleave", () =>
dropzone.classList.remove("is-dragging"),
);
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-dragging"));
dropzone.addEventListener("drop", onB03_File_Drop);
uploadButton = createButton({
@@ -872,10 +793,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
lasFreeCheck.addEventListener("change", () => {
lasFreeDesign = lasFreeCheck.checked;
if (activeProjectId) {
localStorage.setItem(
`b03_las_free_${activeProjectId}`,
lasFreeDesign ? "1" : "0",
);
localStorage.setItem(`b03_las_free_${activeProjectId}`, lasFreeDesign ? "1" : "0");
}
applyLasFreeState();
pageError.textContent = "";
@@ -885,13 +803,11 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
terrainGroup.append(lasFreeRow);
const routePanel = document.createElement("div");
routePanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
routePanel.className = "b03-file__control-panel b03-file__cards-container-panel";
routePanel.append(routeGroup);
const terrainPanel = document.createElement("div");
terrainPanel.className =
"b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.className = "b03-file__control-panel b03-file__cards-container-panel";
terrainPanel.append(terrainGroup);
const cardsContainer = document.createElement("div");
+2 -6
View File
@@ -295,9 +295,7 @@
color: var(--color-royal-amethyst, #3e0079);
background: var(--color-mist-violet, #edecff);
font-size: var(--text-body-sm, 14px);
margin-right: var(
--spacing-8
); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
margin-right: var(--spacing-8); /* 아이콘 우측 마진 추가 (아이콘 좌측 여유 확대 효과) */
}
.b03-file__card-heading {
@@ -348,9 +346,7 @@
font-size: var(--text-body-sm, 14px);
line-height: 1;
padding: 0;
margin-left: var(
--spacing-8
); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
margin-left: var(--spacing-8); /* 취소 버튼 좌측 여유 추가 (취소 버튼 우측 여유 확보) */
transition: all var(--transition-base, 0.2s);
}
+5 -31
View File
@@ -6,40 +6,16 @@ import { ui_locales } from "@ui/ui_template_locale";
* `route_prj`(노선 좌표계)와 `prj`(지형 좌표계)는 확장자가 같아 basename으로 가른다.
*/
export type FileSlot =
| "csv"
| "shx"
| "dbf"
| "cpg"
| "route_prj"
| "las_laz"
| "prj"
| "tfw"
| "tif"
| "dxf";
"csv" | "shx" | "dbf" | "cpg" | "route_prj" | "las_laz" | "prj" | "tfw" | "tif" | "dxf";
/** 왼쪽(계획노선) 컨테이너에 놓이는 슬롯. */
export const ROUTE_SLOTS: readonly FileSlot[] = [
"csv",
"shx",
"dbf",
"cpg",
"route_prj",
];
export const ROUTE_SLOTS: readonly FileSlot[] = ["csv", "shx", "dbf", "cpg", "route_prj"];
/** 오른쪽(지형·LAS) 컨테이너에 놓이는 슬롯. */
export const TERRAIN_SLOTS: readonly FileSlot[] = [
"las_laz",
"prj",
"tfw",
"tif",
];
export const TERRAIN_SLOTS: readonly FileSlot[] = ["las_laz", "prj", "tfw", "tif"];
/** 노선 도형이 shapefile일 때 함께 있어야 하는 슬롯(.cpg는 없으면 CP949). */
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = [
"shx",
"dbf",
"route_prj",
];
export const SHAPEFILE_DEPENDENT_SLOTS: readonly FileSlot[] = ["shx", "dbf", "route_prj"];
export type UploadStatus = "pending" | "uploading" | "completed" | "failed";
export interface SlotConfig {
@@ -179,9 +155,7 @@ export function planSlotAssignments(
return { file, slot: (isRoute ? "route_prj" : "prj") as FileSlot };
}
const config = slotConfigs.find(
(candidate) =>
candidate.slot !== "route_prj" &&
candidate.extensions.includes(extension),
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
);
return { file, slot: config?.slot };
});
+12 -46
View File
@@ -6,14 +6,8 @@
* 갱신하고, 화면 갱신은 호출측이 넘긴 콜백으로만 한다 — 이 파일은 DOM 구조를 모른다.
* ========================================================================== */
import {
PROGRESS_UPDATE_INTERVAL_MS,
UPLOAD_CHUNK_SIZE_MB,
} from "@config/config_frontend";
import {
fetchWorkflowState,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import { PROGRESS_UPDATE_INTERVAL_MS, UPLOAD_CHUNK_SIZE_MB } from "@config/config_frontend";
import { fetchWorkflowState, type WorkflowState } from "../A00_Common/b_workflow_nav";
import { createButton } from "@ui/ui_template_elements";
import { fileFingerprint } from "./B03_FileInput_Fingerprint";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -24,10 +18,7 @@ import {
uploadFileChunk,
type UploadedFileResult,
} from "./B03_FileInput_Api_Fetch";
import {
saveB03UploadedFile,
updateB03AnalysisState,
} from "./B03_FileInput_State";
import { saveB03UploadedFile, updateB03AnalysisState } from "./B03_FileInput_State";
import {
makeSessionKey,
type FileSlotState,
@@ -42,10 +33,7 @@ function L(key: keyof typeof ui_locales): string {
* 완료된 슬롯 재업로드 확인 모달 — 기존 파일·분석 결과가 교체된다는 경고에 사용자의
* 명시적 확인을 받는다(2026-08-04 사용자 지시). 확인 시에만 resolve(true).
*/
export function confirmReplaceUpload(
slotLabel: string,
fileName: string,
): Promise<boolean> {
export function confirmReplaceUpload(slotLabel: string, fileName: string): Promise<boolean> {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
@@ -120,9 +108,7 @@ export async function uploadOneFile(
const chunkSizeBytes = UPLOAD_CHUNK_SIZE_MB * 1024 * 1024;
// 같은 파일을 다시 고른 경우 전송을 통째로 건너뛴다 — 라이다는 한 번에 몇 분씩 걸린다.
const fingerprint = state.uploadSessionId
? null
: await fileFingerprint(file);
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await createUploadSession(
@@ -156,22 +142,11 @@ export async function uploadOneFile(
const start = chunkIndex * chunkSizeBytes;
const end = Math.min(file.size, start + chunkSizeBytes);
const chunkStartedAt = performance.now();
await uploadFileChunk(
projectId,
session,
chunkIndex,
file.slice(start, end),
);
const elapsedSec = Math.max(
0.001,
(performance.now() - chunkStartedAt) / 1000,
);
await uploadFileChunk(projectId, session, chunkIndex, file.slice(start, end));
const elapsedSec = Math.max(0.001, (performance.now() - chunkStartedAt) / 1000);
state.progressBytes = end;
state.speedMbs = (end - start) / 1024 / 1024 / elapsedSec;
state.etaSeconds =
state.speedMbs > 0
? (file.size - end) / 1024 / 1024 / state.speedMbs
: null;
state.etaSeconds = state.speedMbs > 0 ? (file.size - end) / 1024 / 1024 / state.speedMbs : null;
const stored: StoredUploadSession = {
key: storageKey,
@@ -188,10 +163,7 @@ export async function uploadOneFile(
localStorage.setItem(storageKey, JSON.stringify(stored));
const now = performance.now();
if (
now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS ||
chunkIndex === totalChunks - 1
) {
if (now - lastPaintAt > PROGRESS_UPDATE_INTERVAL_MS || chunkIndex === totalChunks - 1) {
lastPaintAt = now;
onProgress();
}
@@ -213,10 +185,7 @@ export async function uploadOneFile(
});
state.progressBytes = file.size;
state.speedMbs =
file.size /
1024 /
1024 /
Math.max(0.001, (performance.now() - startedAt) / 1000);
file.size / 1024 / 1024 / Math.max(0.001, (performance.now() - startedAt) / 1000);
state.etaSeconds = 0;
state.uploadStatus = "completed";
onProgress();
@@ -237,12 +206,9 @@ export async function uploadOneFile(
*
* 전처리가 실패했으면 더 기다릴 게 없으므로 잠금을 푼다.
*/
export function isInitialPipelineRunning(
state: WorkflowState | undefined,
): boolean {
export function isInitialPipelineRunning(state: WorkflowState | undefined): boolean {
if (!state?.stages?.length) return false;
const stageAt = (stageNo: number) =>
state.stages.find((stage) => stage.stage_no === stageNo);
const stageAt = (stageNo: number) => state.stages.find((stage) => stage.stage_no === stageNo);
const fileInput = stageAt(0);
const preprocess = stageAt(1);
const section = stageAt(3);
@@ -8,10 +8,7 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createProgressCircle } from "@ui/ui_template_progress";
// 계획선 색은 2D 지도·B05 배수유역도와 한 곳에서 나온다 — 같은 선을 다른 색으로 그리지 않는다.
import { routeLineColor } from "./B04_PreProcess_UI_MapRender";
import type {
SurfaceBounds,
SurfaceModelSummary,
} from "./B04_PreProcess_Api_Fetch";
import type { SurfaceBounds, SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
import {
bindCursorPivotControls,
bindSurfaceViewerTheme,
@@ -43,11 +40,7 @@ export interface SurfaceTerrainViewer {
setRoute: (points: ReadonlyArray<{ x: number; y: number }>) => void;
setSelection: (sourceFilter: string, method: string) => void;
/** 다른 모델(예: 라이다 지표면)을 반투명으로 겹쳐 본다. 빈 문자열이면 걷어낸다. */
showOverlay: (
sourceFilter: string,
method: string,
smooth: boolean,
) => Promise<boolean>;
showOverlay: (sourceFilter: string, method: string, smooth: boolean) => Promise<boolean>;
applyCameraState: (state: SurfaceCameraState) => void;
onCameraChange: (listener: (state: SurfaceCameraState) => void) => void;
onAxesVisibilityChange: (listener: (visible: boolean) => void) => void;
@@ -242,12 +235,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
scene.background = new THREE.Color(color);
});
const camera = new THREE.PerspectiveCamera(
SURFACE_CAMERA_FOV,
1,
0.01,
100000,
);
const camera = new THREE.PerspectiveCamera(SURFACE_CAMERA_FOV, 1, 0.01, 100000);
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
@@ -291,8 +279,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
function disposeObject(obj: THREE.Object3D) {
obj.traverse((child) => {
const renderable = child as
THREE.Mesh | THREE.Points | THREE.LineSegments;
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
renderable.geometry?.dispose();
const material = renderable.material;
if (Array.isArray(material)) material.forEach((item) => item.dispose());
@@ -414,9 +401,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const material = new THREE.LineBasicMaterial({
color: new THREE.Color(routeLineColor()),
});
routeGroup.add(
new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material),
);
routeGroup.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(vertices), material));
// 노선은 지형 로딩과 따로 도착한다. 지형이 이미 떠 있으면 노선까지 담도록 다시 맞춘다.
if (terrainMesh) fitCamera(terrainMesh);
}
@@ -459,8 +444,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
const fitCamera = (object: THREE.Object3D) => {
const { span } = getFitParams(object);
const aspect =
viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const aspect = viewerArea.clientWidth / Math.max(viewerArea.clientHeight, 1);
const bounds = fitBounds();
const distance = bounds ? getTopFitDistance(bounds, aspect) : span * 1.2;
controls.target.set(0, 0, 0);
@@ -524,15 +508,12 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
const match = currentModelsList.find((m) => {
const typeMatches =
m.model_type.toLowerCase() === activeMethod.toLowerCase();
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const configuredFilter = m.generation_params?.source_filter;
const filterMatches =
(typeof configuredFilter === "string" &&
configuredFilter.toLowerCase() === activeFilter.toLowerCase()) ||
Boolean(
m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()),
);
Boolean(m.model_file_path?.toLowerCase().includes(activeFilter.toLowerCase()));
return typeMatches && filterMatches;
});
@@ -543,8 +524,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth =
(activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -591,8 +571,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors =
child.geometry.hasAttribute("color");
child.material.vertexColors = child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
@@ -605,8 +584,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent =
"3D 메쉬 파일이 없거나 로드할 수 없습니다.";
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
@@ -817,26 +795,18 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
if (terrainMesh && terrainMesh.visible) {
scaleBar.hidden = false;
const dist = camera.position.distanceTo(controls.target);
const metersPerPixel = targetPlaneMetersPerPixel(
dist,
viewerArea.clientHeight,
);
const metersPerPixel = targetPlaneMetersPerPixel(dist, viewerArea.clientHeight);
const roughMeters = 100 * metersPerPixel;
const prettyMeters = niceScaleDistance(roughMeters);
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
scaleLabel.textContent =
prettyMeters >= 1000
? `${(prettyMeters / 1000).toFixed(0)} km`
: `${prettyMeters} m`;
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
} else {
scaleBar.hidden = true;
}
// 등고 라벨 위치 — 화면이 실제로 움직였을 때만 다시 계산한다(매 프레임 재계산은 낭비).
if (
labelsDirty ||
!cameraMatrixSnapshot.equals(camera.matrixWorldInverse)
) {
if (labelsDirty || !cameraMatrixSnapshot.equals(camera.matrixWorldInverse)) {
labelsDirty = false;
cameraMatrixSnapshot.copy(camera.matrixWorldInverse);
labelElements.forEach((label) => {
@@ -878,8 +848,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
intervalForm.addEventListener("submit", async (e) => {
e.preventDefault();
const interval = Number(intervalInput.value);
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null)
return;
if (!Number.isFinite(interval) || interval < 0.5 || currentModelId === null) return;
intervalSubmit.disabled = true;
await loadSelectedContours(currentModelId, currentModelSmooth, true);
intervalSubmit.disabled = false;
@@ -928,13 +897,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
clearOverlay();
return Promise.resolve(false);
}
return loadOverlay(
currentProjectId,
currentModelsList,
sourceFilter,
method,
smooth,
);
return loadOverlay(currentProjectId, currentModelsList, sourceFilter, method, smooth);
},
applyCameraState,
onCameraChange(listener) {
@@ -951,8 +914,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
syncSmoothingSupport();
},
setContourInterval(interval) {
if (Number.isFinite(interval) && interval > 0)
intervalInput.value = String(interval);
if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval);
},
getContourInterval() {
return Number.parseFloat(intervalInput.value);
+10 -31
View File
@@ -5,8 +5,7 @@ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
export interface DesignDrawingItem {
id: string;
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
kind:
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
label: string;
chainage_m: number | null;
confirmed: boolean;
@@ -68,10 +67,7 @@ export interface CrossDesignInfo {
cross_slope_pct?: number;
paved?: boolean;
ditch: DitchSpec;
road_edges?: Record<
"left" | "right",
{ offset_m: number; elevation_m: number }
>;
road_edges?: Record<"left" | "right", { offset_m: number; elevation_m: number }>;
design_elevation_m: number;
cut_area_m2: number;
fill_area_m2: number;
@@ -84,8 +80,7 @@ export interface DesignDrawingResponse {
route_id: number;
id: string;
// blank: 아직 내용을 만들지 않은 도면 — 도각만 실려 온다.
kind:
"cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
kind: "cover" | "longitudinal" | "cross" | "mass_haul" | "watershed" | "blank";
label: string;
drawing: CadDrawing;
confirmed: boolean;
@@ -102,10 +97,7 @@ export interface DesignDrawingConfirmResponse {
design?: CrossDesignInfo | null;
}
async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
try {
@@ -116,17 +108,14 @@ async function requestJson<T>(
signal: controller.signal,
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok)
throw new Error(payload.message ?? `HTTP ${response.status}`);
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
} finally {
window.clearTimeout(timeoutId);
}
}
export function fetchDesignDrawingList(
projectId: string,
): Promise<DesignDrawingListResponse> {
export function fetchDesignDrawingList(projectId: string): Promise<DesignDrawingListResponse> {
return requestJson(`/projects/${projectId}/design-drawings`);
}
@@ -134,9 +123,7 @@ export function fetchDesignDrawing(
projectId: string,
drawingId: string,
): Promise<DesignDrawingResponse> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`,
);
return requestJson(`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}`);
}
export function confirmDesignDrawing(
@@ -154,10 +141,7 @@ export function confirmDesignDrawing(
);
}
export function invalidateDesignDrawing(
projectId: string,
drawingId: string,
): Promise<void> {
export function invalidateDesignDrawing(projectId: string, drawingId: string): Promise<void> {
return requestJson(
`/projects/${projectId}/design-drawings/${encodeURIComponent(drawingId)}/invalidate`,
{ method: "POST" },
@@ -173,16 +157,11 @@ export interface FrameTemplateResponse {
customized: boolean;
}
export function fetchFrameTemplate(
projectId: string,
): Promise<FrameTemplateResponse> {
export function fetchFrameTemplate(projectId: string): Promise<FrameTemplateResponse> {
return requestJson(`/projects/${projectId}/frame-template`);
}
export function saveFrameTemplate(
projectId: string,
drawing: CadDrawing,
): Promise<void> {
export function saveFrameTemplate(projectId: string, drawing: CadDrawing): Promise<void> {
return requestJson(`/projects/${projectId}/frame-template`, {
method: "PUT",
body: JSON.stringify({ drawing }),
@@ -37,9 +37,7 @@ interface Options {
onSaved: () => void;
}
export function createFrameTemplateEditor(
options: Options,
): FrameTemplateEditor {
export function createFrameTemplateEditor(options: Options): FrameTemplateEditor {
let editing = false;
const banner = document.createElement("div");
@@ -94,10 +92,7 @@ export function createFrameTemplateEditor(
: "도각 편집 중 — 기본 도각을 고치면 회사 도각으로 저장됩니다.";
options.sendLoad(response.drawing, null);
} catch (error) {
showToast(
error instanceof Error ? error.message : "도각을 불러오지 못했습니다.",
"error",
);
showToast(error instanceof Error ? error.message : "도각을 불러오지 못했습니다.", "error");
}
}
@@ -117,9 +112,7 @@ export function createFrameTemplateEditor(
leave();
} catch (error) {
showToast(
error instanceof Error
? error.message
: "기본 도각으로 되돌리지 못했습니다.",
error instanceof Error ? error.message : "기본 도각으로 되돌리지 못했습니다.",
"error",
);
} finally {
@@ -134,16 +127,10 @@ export function createFrameTemplateEditor(
const drawing = await options.requestCadDrawing();
await saveFrameTemplate(options.projectId, drawing);
options.onSaved();
showToast(
"도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.",
"success",
);
showToast("도각을 저장했습니다. 확정하지 않은 도면부터 새 도각으로 나옵니다.", "success");
leave();
} catch (error) {
showToast(
error instanceof Error ? error.message : "도각을 저장하지 못했습니다.",
"error",
);
showToast(error instanceof Error ? error.message : "도각을 저장하지 못했습니다.", "error");
} finally {
finishButton.disabled = false;
}
+27 -90
View File
@@ -133,10 +133,7 @@ function buildDrawingSidePanel(
return panel;
}
const drawingButton = (
drawing: DesignDrawingItem,
label: string,
): HTMLButtonElement => {
const drawingButton = (drawing: DesignDrawingItem, label: string): HTMLButtonElement => {
const button = document.createElement("button");
button.type = "button";
button.className = "b07-drawing-button";
@@ -175,8 +172,7 @@ function buildDrawingSidePanel(
const button = drawingButton(drawing, group.label);
// 도각만 있는 도면은 그렇다고 알린다 — 빈 화면을 보고 오류로 오해하지 않게.
button.dataset.pending = String(drawing.kind === "blank");
if (drawing.kind === "blank")
button.title = "준비 중 — 도각만 표시합니다";
if (drawing.kind === "blank") button.title = "준비 중 — 도각만 표시합니다";
panel.append(button);
continue;
}
@@ -198,10 +194,7 @@ function buildDrawingSidePanel(
return panel;
}
const GROUND_TYPE_LABEL: Record<
CrossDesignInfo["ground_type"],
keyof typeof ui_locales
> = {
const GROUND_TYPE_LABEL: Record<CrossDesignInfo["ground_type"], keyof typeof ui_locales> = {
soil: "B06_Design_Ground_Soil",
ripping_rock: "B06_Design_Ground_Ripping",
blasting_rock: "B06_Design_Ground_Blasting",
@@ -218,8 +211,7 @@ function cutSideLabel(mode: CrossDesignInfo["section_mode"]): string {
/** 측구 규격 표시 문자열 (design 신구조: 형식별 ditch spec, F-2 호환). */
function ditchLabel(design: CrossDesignInfo): string {
const ditch = design.ditch;
if (!ditch || ditch.type === "none" || design.ditch_enabled === false)
return "없음";
if (!ditch || ditch.type === "none" || design.ditch_enabled === false) return "없음";
if (ditch.type === "l_type")
return `L형 ${ditch.width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
return `${ditch.top_width_m.toFixed(2)}×${ditch.depth_m.toFixed(2)}m`;
@@ -239,10 +231,7 @@ function infoRow(label: string, value: string): HTMLElement {
}
/** 선택 횡단도의 지반정보/계획정보를 2분할로 렌더한다 (잠정치, B07 확정 시 재계산). */
function buildDesignInfoPanel(
title: string,
design: CrossDesignInfo | null,
): HTMLElement {
function buildDesignInfoPanel(title: string, design: CrossDesignInfo | null): HTMLElement {
const panel = document.createElement("div");
panel.className = "b07-info";
const heading = document.createElement("div");
@@ -252,9 +241,7 @@ function buildDesignInfoPanel(
const confirmed = design?.status === "confirmed";
const badge = document.createElement("span");
badge.className = `b07-info__badge${confirmed ? " b07-info__badge--confirmed" : ""}`;
badge.textContent = confirmed
? L("B07_Info_Confirmed")
: L("B07_Info_Provisional");
badge.textContent = confirmed ? L("B07_Info_Confirmed") : L("B07_Info_Provisional");
heading.append(stationName, badge);
panel.append(heading);
@@ -276,9 +263,7 @@ function buildDesignInfoPanel(
infoRow(L("B07_Info_CutSide"), cutSideLabel(design.section_mode)),
infoRow(
L("B07_Info_DitchSide"),
design.ditch_side === "left"
? L("B06_Design_Ditch_Left")
: L("B06_Design_Ditch_Right"),
design.ditch_side === "left" ? L("B06_Design_Ditch_Left") : L("B06_Design_Ditch_Right"),
),
);
@@ -288,10 +273,7 @@ function buildDesignInfoPanel(
planTitle.textContent = L("B07_Info_Plan_Title");
plan.append(
planTitle,
infoRow(
L("B07_Info_DesignElevation"),
`${design.design_elevation_m.toFixed(2)}m`,
),
infoRow(L("B07_Info_DesignElevation"), `${design.design_elevation_m.toFixed(2)}m`),
infoRow(L("B07_Info_CutSlope"), `1:${design.cut_slope_ratio}`),
infoRow(L("B07_Info_FillSlope"), `1:${design.fill_slope_ratio}`),
infoRow(L("B07_Info_RoadWidth"), `${design.roadbed_width_m.toFixed(2)}m`),
@@ -317,10 +299,8 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
fetchWorkflowState(projectId),
fetchDesignDrawingList(projectId),
]);
if (workflowResult.status === "fulfilled")
workflowState = workflowResult.value;
if (drawingResult.status === "fulfilled")
drawings = drawingResult.value.drawings;
if (workflowResult.status === "fulfilled") workflowState = workflowResult.value;
if (drawingResult.status === "fulfilled") drawings = drawingResult.value.drawings;
else
drawingError =
drawingResult.reason instanceof Error
@@ -350,25 +330,19 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
// 단계 완료 기준은 횡단도만 본다 (종단도 확정 여부는 다음 단계 진행과 무관).
const isCross = (item: DesignDrawingItem): boolean => item.kind === "cross";
let allDrawingsConfirmed =
drawings.some(isCross) &&
drawings.filter(isCross).every((item) => item.confirmed);
drawings.some(isCross) && drawings.filter(isCross).every((item) => item.confirmed);
let resolveSave: ((payload: SaveResult) => void) | undefined;
let drawingListEl: HTMLElement | undefined;
const infoPanelHost = document.createElement("div");
infoPanelHost.className = "b07-info-host";
const updateInfoPanel = (
drawing: DesignDrawingItem,
response: DesignDrawingResponse,
): void => {
const updateInfoPanel = (drawing: DesignDrawingItem, response: DesignDrawingResponse): void => {
if (drawing.kind !== "cross") {
infoPanelHost.replaceChildren();
return;
}
const title = drawing.label;
infoPanelHost.replaceChildren(
buildDesignInfoPanel(title, response.design ?? null),
);
infoPanelHost.replaceChildren(buildDesignInfoPanel(title, response.design ?? null));
};
/**
@@ -406,9 +380,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
) ?? undefined;
const highlightActive = (drawingId: string) => {
drawingListEl
?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button")
.forEach((item) => {
drawingListEl?.querySelectorAll<HTMLButtonElement>(".b07-drawing-button").forEach((item) => {
item.dataset.active = String(item.dataset.drawingId === drawingId);
});
};
@@ -449,24 +421,15 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const drawingCache = new Map<string, Promise<DesignDrawingResponse>>();
/** 도면 하나를 받아 구조물까지 얹은 응답. 같은 id로 겹쳐 부르면 같은 Promise를 쓴다. */
const requestDrawing = (
drawing: DesignDrawingItem,
): Promise<DesignDrawingResponse> => {
const requestDrawing = (drawing: DesignDrawingItem): Promise<DesignDrawingResponse> => {
const cached = drawingCache.get(drawing.id);
if (cached) return cached;
const request = (async () => {
const response = await fetchDesignDrawing(
projectId as string,
drawing.id,
);
const response = await fetchDesignDrawing(projectId as string, drawing.id);
// 구조물(배수관·기슭막이·세월교·BOX·물넘이포장)은 B06 산식이 프론트에 있어
// 여기서 얹는다. 확정본은 이미 구조물이 담겨 저장돼 있으므로 건드리지 않는다.
if (drawing.kind === "cross" && !response.confirmed) {
await appendStructureEntities(
projectId as string,
response.route_id,
response.drawing,
);
await appendStructureEntities(projectId as string, response.route_id, response.drawing);
}
return response;
})().catch((error) => {
@@ -522,9 +485,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
} catch (error) {
cadHost.dataset.loading = "false";
cadHost.dataset.error =
error instanceof Error
? error.message
: "CAD 도면을 불러오지 못했습니다.";
error instanceof Error ? error.message : "CAD 도면을 불러오지 못했습니다.";
showToast(cadHost.dataset.error, "error");
if (currentDrawing) highlightActive(currentDrawing.id);
} finally {
@@ -549,10 +510,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
const requestCadDrawing = (): Promise<SaveResult> =>
new Promise((resolve, reject) => {
resolveSave = resolve;
frame.contentWindow?.postMessage(
{ type: CAD_SAVE_REQUEST_MESSAGE },
window.location.origin,
);
frame.contentWindow?.postMessage({ type: CAD_SAVE_REQUEST_MESSAGE }, window.location.origin);
window.setTimeout(() => {
if (!resolveSave) return;
resolveSave = undefined;
@@ -596,9 +554,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
} catch (error) {
showToast(
error instanceof Error
? error.message
: "현재 도면을 확정하지 못했습니다.",
error instanceof Error ? error.message : "현재 도면을 확정하지 못했습니다.",
"error",
);
} finally {
@@ -628,9 +584,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
showToast("확정을 풀었습니다. 고친 뒤 다시 확정하세요.", "info");
} catch (error) {
showToast(
error instanceof Error
? error.message
: "도면 확정 상태를 되돌리지 못했습니다.",
error instanceof Error ? error.message : "도면 확정 상태를 되돌리지 못했습니다.",
"error",
);
} finally {
@@ -650,11 +604,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
cadHost.prepend(frameEditor.banner);
window.addEventListener("message", (event: MessageEvent<unknown>) => {
if (
event.origin !== window.location.origin ||
event.source !== frame.contentWindow
)
return;
if (event.origin !== window.location.origin || event.source !== frame.contentWindow) return;
const message = event.data as {
type?: string;
detail?: string;
@@ -672,8 +622,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
(item) => item === message.kind,
);
// autoClose:false로 온 안내(백업 되살리기)는 오래 띄운다 — 누를 시간을 준다.
const duration =
message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
const duration = message.durationMs === 0 ? 15000 : (message.durationMs ?? 3000);
const actionId = message.actionId;
showToast(
message.text ?? "",
@@ -693,8 +642,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
} else if (message.type === CAD_LOADED_MESSAGE) {
cadHost.dataset.loading = "false";
} else if (message.type === CAD_ERROR_MESSAGE) {
cadHost.dataset.error =
message.detail ?? "CAD 도면을 표시하지 못했습니다.";
cadHost.dataset.error = message.detail ?? "CAD 도면을 표시하지 못했습니다.";
cadHost.dataset.loading = "false";
showToast(cadHost.dataset.error, "error");
} else if (message.type === CAD_CHANGED_MESSAGE) {
@@ -704,11 +652,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
if (!frameEditor.isEditing()) cadDirty = message.dirty !== false;
} else if (message.type === CAD_NAVIGATE_MESSAGE && message.direction) {
navigateDrawing(message.direction);
} else if (
message.type === CAD_SAVE_RESPONSE_MESSAGE &&
message.drawing &&
resolveSave
) {
} else if (message.type === CAD_SAVE_RESPONSE_MESSAGE && message.drawing && resolveSave) {
const resolve = resolveSave;
resolveSave = undefined;
resolve({
@@ -718,11 +662,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
}
});
const drawingPanel = buildDrawingSidePanel(
drawings,
selectDrawing,
drawingError,
);
const drawingPanel = buildDrawingSidePanel(drawings, selectDrawing, drawingError);
drawingListEl = drawingPanel;
const confirmActions = document.createElement("div");
// 하단 고정은 공용 ui-sidebar-actions로 통일 — 사이드 본문이 [스크롤 영역][액션 줄]로
@@ -744,10 +684,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
onStepClick: (stepIndex) => {
if (!projectId) return;
if (stepIndex > 5 && !allDrawingsConfirmed) {
showToast(
"모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.",
"warning",
);
showToast("모든 설계 도면을 확정한 뒤 다음 단계로 이동할 수 있습니다.", "warning");
return;
}
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
@@ -116,11 +116,7 @@
/* 확정: 좌측 띠 + 측점 글자색을 함께 성공색으로 반영 */
.b07-drawing-button[data-confirmed="true"] {
border-color: color-mix(
in srgb,
var(--color-success) 35%,
var(--color-border)
);
border-color: color-mix(in srgb, var(--color-success) 35%, var(--color-border));
border-left-color: var(--color-success);
}
@@ -18,10 +18,7 @@ import {
wipeoutToolStateMachine,
xlineToolStateMachine,
} from '../tools/draw/construction-tools';
import {
divideToolStateMachine,
measureLengthToolStateMachine,
} from '../tools/draw/divide-tools';
import { divideToolStateMachine, measureLengthToolStateMachine } from '../tools/draw/divide-tools';
import {
boundaryToolStateMachine,
gradientToolStateMachine,
@@ -3,12 +3,7 @@ import type { FC } from 'react';
import { LayerManager } from './LayerManager';
import { PropertiesEditor } from './PropertiesEditor';
import { getInspectorTab, openInspector } from './ui-state';
import {
getActiveLayerId,
getLayers,
setActiveLayerId,
setLayers,
} from '../state';
import { getActiveLayerId, getLayers, setActiveLayerId, setLayers } from '../state';
interface InspectorPanelProps {
collapsed: boolean;
@@ -2,12 +2,7 @@
import type { FC } from 'react';
import type { Entity } from '../entities/Entity';
import { polylineLength, sampleEntityPoints } from '../helpers/geometry/sample-entity';
import {
getEntities,
getLayers,
getSelectedEntities,
setEntities,
} from '../state';
import { getEntities, getLayers, getSelectedEntities, setEntities } from '../state';
import { dashToLineType, LINE_TYPES, LINE_WIDTHS } from './RibbonWidgets';
interface PropertiesEditorProps {
@@ -139,9 +134,7 @@ export const PropertiesEditor: FC<PropertiesEditorProps> = ({ compact = false })
</div>
<div>
<dt></dt>
<dd>
{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}
</dd>
<dd>{points[0] ? `${points[0].x.toFixed(2)}, ${points[0].y.toFixed(2)}` : '-'}</dd>
</div>
<div>
<dt></dt>
@@ -1,6 +1,6 @@
import { type Arc, Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import {EPSILON} from "../App.consts.ts";
import { EPSILON } from '../App.consts.ts';
import { ArcEntity } from './ArcEntity.ts';
describe('ArcEntity.distanceTo', () => {
@@ -89,7 +89,13 @@ export class HatchEntity implements Entity {
}
// 경계선 — 선택·강조 상태를 볼 수 있어야 하므로 항상 그린다
drawController.setLineStyles(highlighted, selected, this.lineColor, this.lineWidth, this.lineDash);
drawController.setLineStyles(
highlighted,
selected,
this.lineColor,
this.lineWidth,
this.lineDash
);
for (let index = 1; index < this.points.length; index++) {
drawController.drawLine(this.points[index - 1], this.points[index]);
}
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import {Point} from "@flatten-js/core";
import {LineEntity} from "./LineEntity.ts";
import {TO_DEGREES} from "../App.consts.ts";
import {getActiveLayerId} from "../state.ts";
import { Point } from '@flatten-js/core';
import { LineEntity } from './LineEntity.ts';
import { TO_DEGREES } from '../App.consts.ts';
import { getActiveLayerId } from '../state.ts';
describe('getAngle', () => {
it('should return 0 for a horizontal line', () => {
@@ -1,7 +1,12 @@
import { type Box, Line, Point, Vector } from '@flatten-js/core'; // Added Box, Segment for completeness
import { round } from 'es-toolkit'; // 1. Mocking for ../state.ts
import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
import {EPSILON, MEASUREMENT_DECIMAL_PLACES, MEASUREMENT_FONT_SIZE, MEASUREMENT_LABEL_OFFSET,} from '../App.consts';
import {
EPSILON,
MEASUREMENT_DECIMAL_PLACES,
MEASUREMENT_FONT_SIZE,
MEASUREMENT_LABEL_OFFSET,
} from '../App.consts';
import type { DrawController } from '../drawControllers/DrawController.ts'; // Import mocked functions after the mock definition // Import mocked functions after the mock definition
import { isEntityHighlighted, isEntitySelected } from '../state.ts';
import { MeasurementEntity } from './MeasurementEntity';
@@ -301,9 +301,7 @@ export class MeasurementEntity implements Entity {
drawController.drawLine(offsetStartPointMargin, offsetStartPointExtend);
drawController.drawLine(offsetEndPointMargin, offsetEndPointExtend);
const distance = String(
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const originalTextDirection = normalUnit.rotate90CW();
let finalTextDirection = originalTextDirection;
if (
@@ -418,9 +416,7 @@ export class MeasurementEntity implements Entity {
];
// Calculate text properties
const distance = String(
round(pointDistance(this.startPoint, this.endPoint), getDimDecimals())
);
const distance = String(round(pointDistance(this.startPoint, this.endPoint), getDimDecimals()));
const worldFactor = annotationWorldFactor();
const textHeight = getDimTextHeight() / worldFactor;
// Estimate width: textString.length * fontSize * aspectRatioFactor
@@ -46,7 +46,10 @@ export function pasteFromClipboard(target?: Point): Entity[] {
copy.lineDash = entity.lineDash;
copy.layerId = entity.layerId;
if (target) {
copy.move(target.x - (clipboard as ClipboardContent).basePoint.x, target.y - (clipboard as ClipboardContent).basePoint.y);
copy.move(
target.x - (clipboard as ClipboardContent).basePoint.x,
target.y - (clipboard as ClipboardContent).basePoint.y
);
}
return copy;
});
@@ -29,20 +29,19 @@ export function calculateAngleGuidesAndSnapPoints() {
const entities = queryEntitiesNearPoint(
worldMouseLocation.x,
worldMouseLocation.y,
maxSnapDistance * 2,
).filter(entity => !getLayerById(entity.layerId)?.isLocked);
maxSnapDistance * 2
).filter((entity) => !getLayerById(entity.layerId)?.isLocked);
const hoveredSnapPoints = getHoveredSnapPoints();
// 객체 스냅 추적(F11)을 끄면 머문 스냅점에서 정렬 가이드를 뻗지 않는다
const eligibleHoveredSnapPoints = getSnapTrackingEnabled()
? hoveredSnapPoints.filter(
hoveredSnapPoint =>
hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME,
(hoveredSnapPoint) => hoveredSnapPoint.milliSecondsHovered > HOVERED_SNAP_POINT_TIME
)
: [];
const eligibleHoveredPoints = eligibleHoveredSnapPoints.map(
hoveredSnapPoint => hoveredSnapPoint.snapPoint.point,
(hoveredSnapPoint) => hoveredSnapPoint.snapPoint.point
);
if (getShouldDrawHelpers()) {
@@ -51,7 +50,7 @@ export function calculateAngleGuidesAndSnapPoints() {
compact([getAngleGuideOriginPoint(), ...eligibleHoveredPoints]),
worldMouseLocation,
angleStep,
maxSnapDistance,
maxSnapDistance
);
setAngleGuideEntities(angleGuides);
setSnapPoint(entitySnapPoint);
@@ -11,7 +11,7 @@ describe('containRectangle', () => {
0,
0,
100,
100, // wrapper: a 100x100 square
100 // wrapper: a 100x100 square
);
// Expected: scale down by factor of 0.5 to fit, centered at (25,25) to (125,125) if it was not restricted,
// but since wrapper is only 100x100, final should be (0,0) + 100x100, scaled rect is 100x100.
@@ -27,7 +27,7 @@ describe('containRectangle', () => {
0,
0,
200,
200, // wrapper: 200x200
200 // wrapper: 200x200
);
// Expected: scale up by factor of 4 to fill as much space as possible while containing
// But scaling up a 50x50 by factor 4 gives 200x200 exactly, centered at (0,0).
@@ -43,7 +43,7 @@ describe('containRectangle', () => {
0,
0,
200,
100, // wrapper: 200x100
100 // wrapper: 200x100
);
// Scale to fit inside 200x100. The width scale = 200/50=4, height scale=100/50=2.
// Min scale = 2, so final size = 100x100.
@@ -64,7 +64,7 @@ describe('containRectangle', () => {
0,
0,
300,
100, // wrapper: 300x100
100 // wrapper: 300x100
);
// Contained AR = 200/50 = 4:1
// Wrapper AR = 300/100 = 3:1
@@ -86,7 +86,7 @@ describe('containRectangle', () => {
0,
0,
200,
200, // wrapper
200 // wrapper
);
// Center as a single point at (100,100)
expect(result).toEqual({ minX: 100, minY: 100, maxX: 100, maxY: 100 });
@@ -101,7 +101,7 @@ describe('containRectangle', () => {
0,
0,
300,
300, // wrapper
300 // wrapper
);
// Scale factor: width scale = 300/100=3, height scale=300/100=3, min=3, so max scale is 3.
// But we want to "contain" fully, ideally it should scale up to take as much space as possible without exceeding,
@@ -118,7 +118,7 @@ describe('containRectangle', () => {
10,
20,
110,
220, // wrapper: 100x200
220 // wrapper: 100x200
);
// Wrapper size: 100x200
// Contained size: 10x30
@@ -143,7 +143,7 @@ describe('containRectangle', () => {
-100,
-50,
100,
50, // wrapper: 200 wide x 100 tall
50 // wrapper: 200 wide x 100 tall
);
// Scale factors: width scale = 200/100=2, height scale=100/50=2
// min scale = 2, final size: 200x100 exactly.
@@ -161,7 +161,7 @@ describe('containRectangle', () => {
0,
0,
100,
100, // wrapper: 100 wide x 100 tall
100 // wrapper: 100 wide x 100 tall
);
expect(result).toEqual({ minX: 0, minY: 25, maxX: 100, maxY: 75 });
});
@@ -6,7 +6,7 @@ export function containRectangle(
wrapperRectMinX: number,
wrapperRectMinY: number,
wrapperRectMaxX: number,
wrapperRectMaxY: number,
wrapperRectMaxY: number
): { minX: number; minY: number; maxX: number; maxY: number } {
// Calculate the width and height of the wrapper rectangle
const wrapperWidth = wrapperRectMaxX - wrapperRectMinX;
@@ -29,10 +29,7 @@ export function containRectangle(
}
// Compute scale factor so contained rect fits within wrapper, maintaining aspect ratio
const scale = Math.min(
wrapperWidth / containedWidth,
wrapperHeight / containedHeight,
);
const scale = Math.min(wrapperWidth / containedWidth, wrapperHeight / containedHeight);
// Compute final displayed dimensions
const displayWidth = containedWidth * scale;
@@ -5,17 +5,13 @@ describe('svgPathToSegments', () => {
it('should handle simple move and line commands', () => {
const path = 'M 10 10 L 20 20';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 20 },
]);
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
});
it('should handle relative line commands', () => {
const path = 'M 10 10 l 10 10';
const segments = svgPathToSegments(path);
expect(segments).toEqual([
{ x1: 10, y1: 10, x2: 20, y2: 20 },
]);
expect(segments).toEqual([{ x1: 10, y1: 10, x2: 20, y2: 20 }]);
});
it('should handle horizontal and vertical lines', () => {
@@ -1,8 +1,8 @@
import {Point} from "@flatten-js/core";
import { Point } from '@flatten-js/core';
import { describe, expect, it } from 'vitest';
import { findClosestEntity } from './find-closest-entity';
import {arcAndLineEntitiesMock} from "./find-closest-entity.mocks.ts";
import {getEntitiesAndLayersFromJsonObject,} from './import-export-handlers/import-entities-from-json.ts';
import { arcAndLineEntitiesMock } from './find-closest-entity.mocks.ts';
import { getEntitiesAndLayersFromJsonObject } from './import-export-handlers/import-entities-from-json.ts';
describe('findClosestEntity', () => {
it('should return the arc as the closest entity', async () => {
@@ -13,32 +13,26 @@ import { sortPointsOnArc } from './sort-points-on-arc';
export function findNeighboringPointsOnArc(
clickedPointOnShape: Point,
arc: ArcEntity,
pointsOnShape: Point[],
pointsOnShape: Point[]
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortPointsOnArc(
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
(arc.getShape() as Arc).center,
(arc.getShape() as Arc).start,
(arc.getShape() as Arc).start
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnShape, point),
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
isPointEqual(clickedPointOnShape, point)
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnArc',
);
throw new Error('Clicked point not found on line in function findNeighboringPointsOnArc');
}
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
return [
sortedPoints[
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
],
sortedPoints[
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
],
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
];
}
@@ -13,31 +13,25 @@ import { sortPointsOnCircle } from './sort-points-on-circle';
export function findNeighboringPointsOnCircle(
clickedPointOnShape: Point,
circle: CircleEntity,
pointsOnShape: Point[],
pointsOnShape: Point[]
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortPointsOnCircle(
uniqWith([...pointsOnShape, clickedPointOnShape], isPointEqual),
(circle.getShape() as Circle).center,
(circle.getShape() as Circle).center
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnShape, point),
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
isPointEqual(clickedPointOnShape, point)
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnCircle',
);
throw new Error('Clicked point not found on line in function findNeighboringPointsOnCircle');
}
// We must make sure that points lying on both sides of the 0 angle are still considered neighbors
// So we add the number of points and take the modulo of the number of points again (so index -1 becomes length - 1)
return [
sortedPoints[
(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length
],
sortedPoints[
(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length
],
sortedPoints[(indexOfClickedPoint + sortedPoints.length - 1) % sortedPoints.length],
sortedPoints[(indexOfClickedPoint + sortedPoints.length + 1) % sortedPoints.length],
];
}
@@ -14,24 +14,19 @@ export function findNeighboringPointsOnLine(
clickedPointOnLine: Point,
lineStartPoint: Point,
lineEndPoint: Point,
pointsOnLine: Point[],
pointsOnLine: Point[]
): [Point, Point] {
// Sort points from start point to endpoint
const sortedPoints = sortBy(
uniqWith(
[lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint],
isPointEqual,
),
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)],
uniqWith([lineStartPoint, ...pointsOnLine, clickedPointOnLine, lineEndPoint], isPointEqual),
[(pointOnLine): number => pointDistance(lineStartPoint, pointOnLine)]
);
const indexOfClickedPoint: number = sortedPoints.findIndex(point =>
isPointEqual(clickedPointOnLine, point),
const indexOfClickedPoint: number = sortedPoints.findIndex((point) =>
isPointEqual(clickedPointOnLine, point)
);
if (indexOfClickedPoint === -1) {
throw new Error(
'Clicked point not found on line in function findNeighboringPointsOnLine',
);
throw new Error('Clicked point not found on line in function findNeighboringPointsOnLine');
}
return [
@@ -28,8 +28,7 @@ export function entitiesToLoop(entities: Entity[], tolerance = DEFAULT_TOLERANCE
while (remaining.length) {
const tail = loop[loop.length - 1];
const index = remaining.findIndex(
(chain) =>
near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
(chain) => near(chain[0], tail, tolerance) || near(chain[chain.length - 1], tail, tolerance)
);
if (index === -1) break; // 끊긴 경계 — 여기까지만 잇는다
const [chain] = remaining.splice(index, 1);
@@ -33,7 +33,10 @@ function sampleCircle(circle: Circle, segments: number): Point[] {
for (let index = 0; index <= segments; index++) {
const angle = (2 * Math.PI * index) / segments;
points.push(
new Point(circle.center.x + circle.r * Math.cos(angle), circle.center.y + circle.r * Math.sin(angle))
new Point(
circle.center.x + circle.r * Math.cos(angle),
circle.center.y + circle.r * Math.sin(angle)
)
);
}
return points;
@@ -48,9 +51,7 @@ export function dedupeConsecutive(points: Point[]): Point[] {
export function sampleEntityPoints(entity: Entity, curveSegments = CURVE_SEGMENTS): Point[] {
if (entity.getType() === EntityName.PolyLine) {
const children = (entity as PolyLineEntity).getEntities();
return dedupeConsecutive(
children.flatMap((child) => sampleEntityPoints(child, curveSegments))
);
return dedupeConsecutive(children.flatMap((child) => sampleEntityPoints(child, curveSegments)));
}
const shape = entity.getShape();
@@ -71,7 +71,9 @@ export function regularPolygonPoints(center: Point, vertex: Point, sides: number
const points: Point[] = [];
for (let index = 0; index < count; index++) {
const angle = startAngle + (2 * Math.PI * index) / count;
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
points.push(
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
);
}
points.push(points[0].clone());
return points;
@@ -101,11 +103,7 @@ export function ellipsePoints(
/** 조정점을 지나는 부드러운 곡선 (Catmull-Rom → 폴리선) */
export function splinePoints(controlPoints: Point[], segmentsPerSpan = 12): Point[] {
if (controlPoints.length < 3) return [...controlPoints];
const extended = [
controlPoints[0],
...controlPoints,
controlPoints[controlPoints.length - 1],
];
const extended = [controlPoints[0], ...controlPoints, controlPoints[controlPoints.length - 1]];
const result: Point[] = [];
for (let index = 1; index < extended.length - 2; index++) {
const p0 = extended[index - 1];
@@ -158,7 +156,9 @@ function halfArcPoints(from: Point, to: Point, segments = 8): Point[] {
const points: Point[] = [];
for (let index = 0; index <= segments; index++) {
const angle = baseAngle + Math.PI - (Math.PI * index) / segments;
points.push(new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle)));
points.push(
new Point(center.x + radius * Math.cos(angle), center.y + radius * Math.sin(angle))
);
}
return points;
}
@@ -1,32 +1,30 @@
import { LineEntity } from '../entities/LineEntity';
import { times } from './times';
import { Point } from '@flatten-js/core';
import {ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH} from "../App.consts.ts";
import {getActiveLayerId} from "../state.ts";
import { ANGLE_GUIDES_COLOR, ANGLE_GUIDES_DASH } from '../App.consts.ts';
import { getActiveLayerId } from '../state.ts';
export function getAngleGuideLines(
firstPoint: Point,
angleStep: number,
): LineEntity[] {
export function getAngleGuideLines(firstPoint: Point, angleStep: number): LineEntity[] {
// Only for 180 degrees since we draw lines that are infinite in both directions,
// so we only need to fill half a circle to fill the complete circle
return times(180 / angleStep, i => {
return times(180 / angleStep, (i) => {
const angle = i * angleStep;
const angleRad = angle * (Math.PI / 180);
const x = firstPoint.x + Math.cos(angleRad);
const y = firstPoint.y + Math.sin(angleRad);
const angleLine = new LineEntity(getActiveLayerId(),
const angleLine = new LineEntity(
getActiveLayerId(),
new Point(
firstPoint.x - 10000 * (x - firstPoint.x),
firstPoint.y - 10000 * (y - firstPoint.y),
firstPoint.y - 10000 * (y - firstPoint.y)
),
new Point(
firstPoint.x + 10000 * (x - firstPoint.x),
firstPoint.y + 10000 * (y - firstPoint.y),
),
firstPoint.y + 10000 * (y - firstPoint.y)
)
);
angleLine.lineColor = ANGLE_GUIDES_COLOR;
angleLine.lineDash = ANGLE_GUIDES_DASH;
return angleLine
return angleLine;
});
}
@@ -1,4 +1,4 @@
import type {Entity} from "../entities/Entity.ts";
import type { Entity } from '../entities/Entity.ts';
export interface BoundingBox {
minX: number;
@@ -15,7 +15,7 @@ export function convertSvgToPngBlob(
svgLines: string[],
width: number,
height: number,
margin: number,
margin: number
): Promise<Blob> {
return new Promise<Blob>((resolve, reject) => {
const canvas = document.createElement('canvas');
@@ -40,7 +40,7 @@ export function convertSvgToPngBlob(
URL.revokeObjectURL(url);
canvas.toBlob(blob => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
@@ -57,12 +57,7 @@ export async function exportEntitiesToPngFile() {
const entities = getEntities();
const svg = convertEntitiesToSvgString(entities);
const pngDataBlob: Blob = await convertSvgToPngBlob(
svg.svgLines,
svg.width,
svg.height,
20,
);
const pngDataBlob: Blob = await convertSvgToPngBlob(svg.svgLines, svg.width, svg.height, 20);
saveAs(pngDataBlob, 'open-web-cad--drawing.png');
}
@@ -2,7 +2,7 @@ import {LOCAL_STORAGE_KEY} from '../../App.types.ts';
import { setActiveLayerId, setEntities, setLayers } from '../../state.ts';
import { getNewLayer } from '../get-new-layer.ts';
import { getEntitiesAndLayersFromJsonString } from './import-entities-from-json.ts';
import type {JsonDrawingFileDeserialized} from "./export-entities-to-json.ts";
import type { JsonDrawingFileDeserialized } from './export-entities-to-json.ts';
export async function importEntitiesAndLayersFromLocalStorage(): Promise<void> {
const file = await getEntitiesAndLayersFromLocalStorage();
@@ -1,12 +1,12 @@
export interface SvgParseResult {
type: string
children: Children[]
type: string;
children: Children[];
}
export interface Children {
type: string
tagName: string
properties: Record<string, string>
children: Children[]
metadata?: string
type: string;
tagName: string;
properties: Record<string, string>;
children: Children[];
metadata?: string;
}
@@ -3,10 +3,8 @@
* Load the image data
* Convert it to a base64 string
*/
export function importImageFromFile(
file: File | null | undefined,
): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>(resolve => {
export function importImageFromFile(file: File | null | undefined): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve) => {
if (!file) return;
const img = new Image();
@@ -1,6 +1,6 @@
import type {Point} from "@flatten-js/core";
import type {StartAndEndpointEntity} from "../App.types.ts";
import {isPointEqual} from "./is-point-equal.ts";
import type { Point } from '@flatten-js/core';
import type { StartAndEndpointEntity } from '../App.types.ts';
import { isPointEqual } from './is-point-equal.ts';
/**
* Check if entities form a closed loop polygon
@@ -2,8 +2,5 @@ import type { Point } from '@flatten-js/core';
import { EPSILON } from '../App.consts';
export function isPointEqual(point1: Point, point2: Point): boolean {
return (
Math.abs(point1.x - point2.x) < EPSILON &&
Math.abs(point1.y - point2.y) < EPSILON
);
return Math.abs(point1.x - point2.x) < EPSILON && Math.abs(point1.y - point2.y) < EPSILON;
}
@@ -1,4 +1,4 @@
import type {KeyboardEvent} from "react";
import type { KeyboardEvent } from 'react';
export function keyboardHandler(clickHandler: () => void) {
return (evt: KeyboardEvent) => {
@@ -7,7 +7,7 @@ export function mapNumberRange(
startSourceRange: number,
endSourceRange: number,
startTargetRange: number,
endTargetRange: number,
endTargetRange: number
): number {
// Handle the case where source range has zero length
if (startSourceRange === endSourceRange) {
@@ -1,4 +1,4 @@
import type {LineEntity} from "../entities/LineEntity.ts";
import type { LineEntity } from '../entities/LineEntity.ts';
export function mirrorAngleOverAxis(angle: number, mirrorAxis: LineEntity) {
const mirrorAngle = mirrorAxis.getAngle();
@@ -1,10 +1,10 @@
import {describe, expect, it} from "vitest";
import {Point} from "@flatten-js/core";
import { describe, expect, it } from 'vitest';
import { Point } from '@flatten-js/core';
import { mirrorPointOverAxis } from './mirror-point-over-axis';
import {LineEntity} from "../entities/LineEntity.ts";
import {getActiveLayerId} from "../state.ts";
import { LineEntity } from '../entities/LineEntity.ts';
import { getActiveLayerId } from '../state.ts';
describe("mirrorPointOverAxis", () => {
describe('mirrorPointOverAxis', () => {
it('should mirror if the axis is horizontal', () => {
const point = new Point(100, 100);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 50), new Point(50, 50));
@@ -13,7 +13,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirroredPoint.y).toBe(0);
});
it("mirrors a point over a vertical axis", () => {
it('mirrors a point over a vertical axis', () => {
const point = new Point(3, 4);
const axis = new LineEntity(getActiveLayerId(), new Point(0, -1), new Point(0, 1)); // Vertical line at x=0
const mirrored = mirrorPointOverAxis(point, axis);
@@ -21,7 +21,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(4);
});
it("mirrors a point over the diagonal line y = x", () => {
it('mirrors a point over the diagonal line y = x', () => {
const point = new Point(3, 4);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(1, 1)); // Line y=x
const mirrored = mirrorPointOverAxis(point, axis);
@@ -30,7 +30,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(3);
});
it("returns the same point if the point lies on the mirror axis", () => {
it('returns the same point if the point lies on the mirror axis', () => {
const point = new Point(1, 1);
const axis = new LineEntity(getActiveLayerId(), new Point(0, 0), new Point(2, 2)); // Point (1,1) lies on this line
const mirrored = mirrorPointOverAxis(point, axis);
@@ -38,7 +38,7 @@ describe("mirrorPointOverAxis", () => {
expect(mirrored.y).toBeCloseTo(1);
});
it("returns the original point when mirrored twice", () => {
it('returns the original point when mirrored twice', () => {
const point = new Point(5, 7);
const axis = new LineEntity(getActiveLayerId(), new Point(2, 3), new Point(8, 11)); // Arbitrary axis
const mirrored = mirrorPointOverAxis(point, axis);
@@ -1,5 +1,5 @@
import { Point, type Segment } from '@flatten-js/core';
import type {LineEntity} from "../entities/LineEntity.ts";
import type { LineEntity } from '../entities/LineEntity.ts';
export function mirrorPointOverAxis(point: Point, mirrorAxis: LineEntity) {
const mirrorAxisSegment = mirrorAxis.getShape() as Segment;
@@ -1,10 +1,6 @@
import { Point, Vector } from '@flatten-js/core';
export function rotatePoint(
point: Point,
rotateOrigin: Point,
angle: number,
): Point {
export function rotatePoint(point: Point, rotateOrigin: Point, angle: number): Point {
const vector = new Vector(rotateOrigin, point);
const rotatedVector = vector.rotate(angle);
return new Point(rotatedVector.x, rotatedVector.y);
@@ -1,10 +1,6 @@
import { Point, Vector } from '@flatten-js/core';
export function scalePoint(
point: Point,
scaleOrigin: Point,
scaleFactor: number,
): Point {
export function scalePoint(point: Point, scaleOrigin: Point, scaleFactor: number): Point {
const vector = new Vector(scaleOrigin, point);
const scaledVector = vector.scale(scaleFactor - 1, scaleFactor - 1);
return new Point(point.x + scaledVector.x, point.y + scaledVector.y);
@@ -1,9 +1,5 @@
import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController';
import {
getGridEnabled,
getHighlightedEntityIds,
setHighlightedEntityIds,
} from '../state';
import { getGridEnabled, getHighlightedEntityIds, setHighlightedEntityIds } from '../state';
import { drawEntities } from './draw-functions';
import { getSceneVersion } from './scene-version';
import { queryEntitiesInBox } from './spatial-index';
@@ -130,7 +126,11 @@ export function drawScene(drawController: ScreenCanvasDrawController, now: numbe
// Grid lines are screen-fixed (drawn in clear()), so blitting a shifted
// bitmap would drag the grid along — always re-render while grid is on.
if (paramsChanged || getGridEnabled() || (offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)) {
if (
paramsChanged ||
getGridEnabled() ||
(offsetChanged && now - lastOffsetChangeAt >= PAN_SETTLE_MS)
) {
rebuildScene(drawController);
}
@@ -12,22 +12,20 @@ import { ArcEntity } from '../entities/ArcEntity';
export function sortPointsOnArc(
pointsOnArc: Point[],
centerPoint: Point,
startPoint: Point,
startPoint: Point
): Point[] {
const firstPointAngle = ArcEntity.getAngle(centerPoint, startPoint);
// Angles calculated from start point (0 degrees) and up
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map(point => {
const pointsWithAngles: PointWithAngle[] = pointsOnArc.map((point) => {
return {
point,
// Ensure all angles are between 0 (start point) and < 2PI,
// so we can sort them starting at the start point angle
angle:
(new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) %
(2 * Math.PI),
angle: (new Line(centerPoint, point).slope - firstPointAngle + 2 * Math.PI) % (2 * Math.PI),
};
});
return sortBy(pointsWithAngles, [
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
]).map(pointsWithAngle => pointsWithAngle.point);
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
(pointsWithAngle) => pointsWithAngle.point
);
}
@@ -7,17 +7,14 @@ import type { PointWithAngle } from './helpers.types';
* @param pointsOnCircle
* @param centerPoint
*/
export function sortPointsOnCircle(
pointsOnCircle: Point[],
centerPoint: Point,
): Point[] {
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map(point => {
export function sortPointsOnCircle(pointsOnCircle: Point[], centerPoint: Point): Point[] {
const pointsWithAngles: PointWithAngle[] = pointsOnCircle.map((point) => {
return {
point,
angle: new Line(centerPoint, point).slope,
};
});
return sortBy(pointsWithAngles, [
(pointWithAngle: PointWithAngle) => pointWithAngle.angle,
]).map(pointsWithAngle => pointsWithAngle.point);
return sortBy(pointsWithAngles, [(pointWithAngle: PointWithAngle) => pointWithAngle.angle]).map(
(pointsWithAngle) => pointsWithAngle.point
);
}
@@ -1,7 +1,4 @@
export function times<T>(
num: number,
iterateeFunc: (i: number) => T = (i: number) => i as T,
): T[] {
export function times<T>(num: number, iterateeFunc: (i: number) => T = (i: number) => i as T): T[] {
let i = 0;
const items = [];
while (i < num) {
@@ -11,7 +11,7 @@ export function trackHoveredSnapPoint(
worldHoveredSnapPoints: HoverPoint[],
setHoveredSnapPoints: (hoveredSnapPoints: HoverPoint[]) => void,
maxHoverDistance: number,
elapsedTime: number,
elapsedTime: number
) {
if (!worldSnapPoint) {
return;
@@ -22,18 +22,14 @@ export function trackHoveredSnapPoint(
// Angle guide points should never be marked
if (lastHoveredPoint) {
if (
pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) <
maxHoverDistance
) {
if (pointDistance(worldSnapPoint.point, lastHoveredPoint.snapPoint.point) < maxHoverDistance) {
// Last hovered snap point is still the current closest snap point
// Increase the hover time
newHoverSnapPoints = [
...worldHoveredSnapPoints.slice(0, worldHoveredSnapPoints.length - 1),
{
...lastHoveredPoint,
milliSecondsHovered:
lastHoveredPoint.milliSecondsHovered + elapsedTime,
milliSecondsHovered: lastHoveredPoint.milliSecondsHovered + elapsedTime,
},
];
} else {
@@ -69,9 +65,6 @@ export function trackHoveredSnapPoint(
];
}
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(
0,
MAX_MARKED_SNAP_POINTS,
);
const newHoverSnapPointsTruncated = newHoverSnapPoints.slice(0, MAX_MARKED_SNAP_POINTS);
setHoveredSnapPoints(newHoverSnapPointsTruncated);
}
@@ -1,8 +1,8 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
/**
* AlignBottom tool state machine
@@ -1,9 +1,9 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import {middle} from "../helpers/middle.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
import { middle } from '../helpers/middle.ts';
/**
* AlignCenterHorizontal tool state machine
@@ -1,8 +1,8 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
/**
* AlignLeft tool state machine
@@ -1,9 +1,9 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import {middle} from "../helpers/middle.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
import { middle } from '../helpers/middle.ts';
/**
* AlignCenterVertical tool state machine
@@ -1,8 +1,8 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
/**
* AlignRight tool state machine
@@ -1,6 +1,9 @@
import { assign, type MachineContext, sendTo } from 'xstate';
import type { Entity } from '../entities/Entity.ts';
import {type BoundingBox, getBoundingBoxOfMultipleEntities,} from '../helpers/get-bounding-box-of-multiple-entities.ts';
import {
type BoundingBox,
getBoundingBoxOfMultipleEntities,
} from '../helpers/get-bounding-box-of-multiple-entities.ts';
import {
getSelectedEntities,
getSelectedEntityIds,
@@ -11,7 +14,13 @@ import {
} from '../state.ts';
import type { Tool } from '../tools.ts';
import { selectToolStateMachine } from './select-tool.ts';
import type {DrawEvent, KeyboardEnterEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types.ts';
import type {
DrawEvent,
KeyboardEnterEvent,
MouseClickEvent,
StateEvent,
ToolContext,
} from './tool.types.ts';
export interface AlignContext extends ToolContext {}
@@ -1,8 +1,8 @@
import { Tool } from '../tools';
import { createMachine } from 'xstate';
import type {BoundingBox} from "../helpers/get-bounding-box-of-multiple-entities.ts";
import {GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE} from "./align-tool.helpers.ts";
import type {Entity} from "../entities/Entity.ts";
import type { BoundingBox } from '../helpers/get-bounding-box-of-multiple-entities.ts';
import { GET_ALIGN_ACTION, GET_ALIGN_TOOL_STATE } from './align-tool.helpers.ts';
import type { Entity } from '../entities/Entity.ts';
/**
* AlignTop tool state machine
@@ -97,7 +97,7 @@ export const dimAngularToolStateMachine = createSequenceTool({
const startAngle = Math.atan2(first.y - vertex.y, first.x - vertex.x);
const endAngle = Math.atan2(second.y - vertex.y, second.x - vertex.x);
const sweep = ((endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI));
const sweep = (endAngle - startAngle + 2 * Math.PI) % (2 * Math.PI);
const midAngle = startAngle + sweep / 2;
const degrees = (sweep * 180) / Math.PI;
@@ -2,8 +2,8 @@ import {type Point, Vector} from '@flatten-js/core';
import { assign, createMachine, sendTo } from 'xstate';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH, TO_RADIANS } from '../App.consts.ts';
import type { Entity } from '../entities/Entity';
import {LineEntity} from "../entities/LineEntity.ts";
import {getPointFromEvent} from "../helpers/get-point-from-event.ts";
import { LineEntity } from '../entities/LineEntity.ts';
import { getPointFromEvent } from '../helpers/get-point-from-event.ts';
import {
addEntities,
getActiveLayerId,
@@ -10,13 +10,13 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { LineEntity } from '../entities/LineEntity';
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
import { moveEntities } from './move-tool.helpers';
export interface CopyContext extends ToolContext {
@@ -144,10 +144,7 @@ export const copyToolStateMachine = createMachine(
},
on: {
MOUSE_CLICK: {
actions: [
CopyAction.RECORD_START_POINT,
CopyAction.COPY_SELECTION_BEFORE_COPY,
],
actions: [CopyAction.RECORD_START_POINT, CopyAction.COPY_SELECTION_BEFORE_COPY],
target: CopyState.WAITING_FOR_END_COPY_POINT,
},
ESC: {
@@ -211,39 +208,31 @@ export const copyToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before copying them, so we can restore them when the user cancels the copy action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
copiedEntities: selectedEntities,
};
}),
[CopyAction.DRAW_TEMP_COPY_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[COPY] Calling draw temp copy line without a start point',
);
throw new Error('[COPY] Calling draw temp copy line without a start point');
}
const endPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// Copy the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be copied;
const movedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y,
endPointTemp.y - context.startPoint.y
);
// // Draw a dashed line between the start copy point and the current mouse location
const activeCopyLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp,
endPointTemp
);
activeCopyLine.lineColor = GUIDE_LINE_COLOR;
activeCopyLine.lineWidth = GUIDE_LINE_WIDTH;
@@ -252,20 +241,16 @@ export const copyToolStateMachine = createMachine(
},
[CopyAction.COPY_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[COPY] Calling copy selection without a start point',
);
throw new Error('[COPY] Calling copy selection without a start point');
}
// Copy the entities one final time
const currentEndPoint = (event as MouseClickEvent).worldMouseLocation;
const copiedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
const copiedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
moveEntities(
copiedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y,
currentEndPoint.y - context.startPoint.y
);
// Switch the copied entities back from the ghost helper entities to the real entities
@@ -292,5 +277,5 @@ export const copyToolStateMachine = createMachine(
}),
...selectToolStateMachine.implementations.actions,
},
},
}
);
@@ -9,7 +9,13 @@ import {
} from '../../helpers/geometry/shape-points';
import { addEntities, getActiveLayerId } from '../../state';
import { Tool } from '../../tools';
import { arcEntity, lineEntity, pointEntity, polyLineEntity, styled } from '../factories/entity-factory';
import {
arcEntity,
lineEntity,
pointEntity,
polyLineEntity,
styled,
} from '../factories/entity-factory';
import { createSequenceTool } from '../factories/sequence-tool';
export const plineToolStateMachine = createSequenceTool({
@@ -138,7 +144,11 @@ export const donutToolStateMachine = createSequenceTool({
},
});
function donutEntities(innerDiameter: number, outerDiameter: number, center: Parameters<typeof pointEntity>[0]): Entity[] {
function donutEntities(
innerDiameter: number,
outerDiameter: number,
center: Parameters<typeof pointEntity>[0]
): Entity[] {
const circles: Entity[] = [];
for (const diameter of [innerDiameter, outerDiameter]) {
if (diameter > 0) {
@@ -1,6 +1,10 @@
/** 등분(DIVIDE)·길이분할(MEASURE) — 객체를 자르지 않고 점만 놓는다 */
import { toast } from 'react-toastify';
import { dividePoints, measurePoints, sampleEntityPoints } from '../../helpers/geometry/sample-entity';
import {
dividePoints,
measurePoints,
sampleEntityPoints,
} from '../../helpers/geometry/sample-entity';
import { addEntities } from '../../state';
import { Tool } from '../../tools';
import { pointEntity } from '../factories/entity-factory';
@@ -57,9 +57,7 @@ export const hatchToolStateMachine = createSequenceTool({
export const gradientToolStateMachine = createSequenceTool({
tool: Tool.GRADIENT,
helpers: false,
steps: [
{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' },
],
steps: [{ kind: 'selection', instructions: '그라데이션을 넣을 경계 객체를 선택한 뒤 ENTER.' }],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
@@ -80,9 +78,7 @@ export const gradientToolStateMachine = createSequenceTool({
export const boundaryToolStateMachine = createSequenceTool({
tool: Tool.BOUNDARY,
helpers: false,
steps: [
{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' },
],
steps: [{ kind: 'selection', instructions: '경계를 뽑을 객체를 선택한 뒤 ENTER를 누르십시오.' }],
commit: (input) => {
const loop = loopFromSelection(input);
if (!loop.length) return;
@@ -6,7 +6,7 @@ import {CircleEntity} from '../entities/CircleEntity.ts';
import { EntityName } from '../entities/Entity.ts';
import { RectangleEntity } from '../entities/RectangleEntity.ts';
import { getEntities, setEntities } from '../state.ts';
import {eraseCircleSegment, getAllIntersectionPoints,} from './eraser-tool.helpers.ts';
import { eraseCircleSegment, getAllIntersectionPoints } from './eraser-tool.helpers.ts';
import { handleMouseClick } from './eraser-tool.ts';
describe('erase-tool', () => {
@@ -11,7 +11,15 @@ import {
} from '../state';
import { Tool } from '../tools';
import { Actor, assign, createMachine } from 'xstate';
import {ActorEvent, type DrawEvent, type FileSelectedEvent, type MouseClickEvent, type PointInputEvent, type StateEvent, type ToolContext,} from './tool.types';
import {
ActorEvent,
type DrawEvent,
type FileSelectedEvent,
type MouseClickEvent,
type PointInputEvent,
type StateEvent,
type ToolContext,
} from './tool.types';
import { ImageEntity } from '../entities/ImageEntity';
import { getContainRectangleInsideRectangle } from './image-import-tool.helpers';
import { RectangleEntity } from '../entities/RectangleEntity';
@@ -164,31 +172,28 @@ export const imageImportToolStateMachine = createMachine(
[ImageImportAction.DRAW_TEMP_IMAGE_IMPORT]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT'
);
}
if (!context.imageElement) {
throw new Error(
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT',
'[IMAGE_IMPORT] imageElement is not set when calling DRAW_TEMP_IMAGE_IMPORT'
);
}
if (
isPointEqual(
context.startPoint,
(event as DrawEvent).drawController.getWorldMouseLocation(),
(event as DrawEvent).drawController.getWorldMouseLocation()
)
) {
return; // Can't draw an image that is 0 pixels wide
}
const endPoint = getPointFromEvent(
context.startPoint,
event as PointInputEvent,
);
const endPoint = getPointFromEvent(context.startPoint, event as PointInputEvent);
const containRectangle = getContainRectangleInsideRectangle(
context.imageElement.naturalWidth,
context.imageElement.naturalHeight,
context.startPoint,
endPoint,
endPoint
);
if (!containRectangle) {
return;
@@ -198,11 +203,11 @@ export const imageImportToolStateMachine = createMachine(
context.imageElement,
containRectangle.low,
containRectangle.high,
0,
0
);
const draggedRectangle = new RectangleEntity(
getActiveLayerId(),
twoPointBoxToPolygon(context.startPoint, endPoint),
twoPointBoxToPolygon(context.startPoint, endPoint)
);
setGhostHelperEntities([activeImage]);
setAngleGuideEntities([draggedRectangle]);
@@ -210,12 +215,12 @@ export const imageImportToolStateMachine = createMachine(
[ImageImportAction.DRAW_FINAL_IMAGE_IMPORT]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT',
'[IMAGE_IMPORT] startPoint is not set when calling DRAW_TEMP_IMAGE_IMPORT'
);
}
if (!context.imageElement) {
throw new Error(
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT',
'[IMAGE_IMPORT] imageArrayBuffer is not set when calling DRAW_TEMP_IMAGE_IMPORT'
);
}
@@ -223,7 +228,7 @@ export const imageImportToolStateMachine = createMachine(
context.imageElement.naturalWidth,
context.imageElement.naturalHeight,
context.startPoint,
(event as MouseClickEvent).worldMouseLocation,
(event as MouseClickEvent).worldMouseLocation
);
if (!containRectangle) {
@@ -233,7 +238,7 @@ export const imageImportToolStateMachine = createMachine(
const activeImage = new ImageEntity(
getActiveLayerId(),
context.imageElement,
boxToPolygon(containRectangle),
boxToPolygon(containRectangle)
);
addEntities([activeImage], true);
},
@@ -241,5 +246,5 @@ export const imageImportToolStateMachine = createMachine(
setActiveToolActor(new Actor(selectToolStateMachine));
},
},
},
}
);
@@ -50,14 +50,7 @@ export const chamferToolStateMachine = createSequenceTool({
applyCorner(
first,
second,
chamferLines(
first,
input.pick(2),
second,
input.pick(3),
input.number(0),
input.number(1)
)
chamferLines(first, input.pick(2), second, input.pick(3), input.number(0), input.number(1))
);
},
});
@@ -80,14 +80,8 @@ export function filletLines(
}
const tangentDistance = radius / Math.tan(angle / 2);
const tangentA = new Point(
corner.x + ua.x * tangentDistance,
corner.y + ua.y * tangentDistance
);
const tangentB = new Point(
corner.x + ub.x * tangentDistance,
corner.y + ub.y * tangentDistance
);
const tangentA = new Point(corner.x + ua.x * tangentDistance, corner.y + ua.y * tangentDistance);
const tangentB = new Point(corner.x + ub.x * tangentDistance, corner.y + ub.y * tangentDistance);
const bisector = unit(new Point(0, 0), new Point(ua.x + ub.x, ua.y + ub.y));
const centerDistance = radius / Math.sin(angle / 2);
@@ -129,7 +129,11 @@ export const lengthenToolStateMachine = createSequenceTool({
tool: Tool.LENGTHEN,
steps: [
{ kind: 'entity', instructions: '길이를 바꿀 선을 늘릴 쪽 끝 근처에서 선택하십시오.' },
{ kind: 'number', instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.', defaultValue: 10 },
{
kind: 'number',
instructions: '증분 길이를 입력하십시오 (음수는 단축) <10>.',
defaultValue: 10,
},
],
commit: (input) => {
const entity = input.entity(0);
@@ -11,14 +11,14 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
import { compact } from 'es-toolkit';
import { moveEntities } from './move-tool.helpers';
import { LineEntity } from '../entities/LineEntity';
import {GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH,} from '../App.consts';
import { GUIDE_LINE_COLOR, GUIDE_LINE_STYLE, GUIDE_LINE_WIDTH } from '../App.consts';
export interface MoveContext extends ToolContext {
startPoint: Point | null;
@@ -145,10 +145,7 @@ export const moveToolStateMachine = createMachine(
},
on: {
MOUSE_CLICK: {
actions: [
MoveAction.RECORD_START_POINT,
MoveAction.COPY_SELECTION_BEFORE_MOVE,
],
actions: [MoveAction.RECORD_START_POINT, MoveAction.COPY_SELECTION_BEFORE_MOVE],
target: MoveState.WAITING_FOR_END_MOVE_POINT,
},
ESC: {
@@ -214,39 +211,31 @@ export const moveToolStateMachine = createMachine(
return {
startPoint: context.startPoint,
// Make a copy of the selected entities before moving them, so we can restore them when the user cancels the move action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
movedEntities: selectedEntities,
};
}),
[MoveAction.DRAW_TEMP_MOVE_ENTITIES]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[MOVE] Calling draw temp move line without a start point',
);
throw new Error('[MOVE] Calling draw temp move line without a start point');
}
const endPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
const endPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// Move the entities to the new location
// Draw all selected entities according to translation vector, so the user gets visual feedback of where the entities will be moved;
const movedEntities = context.originalSelectedEntities.map(entity =>
entity.clone(),
);
const movedEntities = context.originalSelectedEntities.map((entity) => entity.clone());
moveEntities(
movedEntities,
endPointTemp.x - context.startPoint.x,
endPointTemp.y - context.startPoint.y,
endPointTemp.y - context.startPoint.y
);
// // Draw a dashed line between the start move point and the current mouse location
const activeMoveLine = new LineEntity(
getActiveLayerId(),
context.startPoint as Point,
endPointTemp,
endPointTemp
);
activeMoveLine.lineColor = GUIDE_LINE_COLOR;
activeMoveLine.lineWidth = GUIDE_LINE_WIDTH;
@@ -255,9 +244,7 @@ export const moveToolStateMachine = createMachine(
},
[MoveAction.MOVE_SELECTION]: ({ context, event }) => {
if (!context.startPoint) {
throw new Error(
'[MOVE] Calling move selection without a start point',
);
throw new Error('[MOVE] Calling move selection without a start point');
}
// Move the entities one final time
@@ -265,7 +252,7 @@ export const moveToolStateMachine = createMachine(
moveEntities(
context.originalSelectedEntities,
currentEndPoint.x - context.startPoint.x,
currentEndPoint.y - context.startPoint.y,
currentEndPoint.y - context.startPoint.y
);
// Switch the moved entities back from the ghost helper entities to the real entities
@@ -294,5 +281,5 @@ export const moveToolStateMachine = createMachine(
}),
...selectToolStateMachine.implementations.actions,
},
},
}
);
@@ -10,7 +10,7 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -117,10 +117,7 @@ export const rotateToolStateMachine = createMachine(
}),
},
ESC: {
actions: [
RotateAction.DESELECT_ENTITIES,
RotateAction.INIT_ROTATE_TOOL,
],
actions: [RotateAction.DESELECT_ENTITIES, RotateAction.INIT_ROTATE_TOOL],
},
ENTER: {
// Forward the event to the select tool
@@ -184,10 +181,7 @@ export const rotateToolStateMachine = createMachine(
actions: [RotateAction.DRAW_TEMP_ROTATE_ENTITIES],
},
MOUSE_CLICK: {
actions: [
RotateAction.ROTATE_SELECTION,
RotateAction.DESELECT_ENTITIES,
],
actions: [RotateAction.ROTATE_SELECTION, RotateAction.DESELECT_ENTITIES],
target: RotateState.WAITING_FOR_SELECTION,
},
ESC: {
@@ -208,27 +202,22 @@ export const rotateToolStateMachine = createMachine(
[RotateAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(
({ context, event }): RotateContext => {
setAngleGuideOriginPoint(
(event as MouseClickEvent).worldMouseLocation,
);
[RotateAction.RECORD_ROTATION_ORIGIN]: assign(({ context, event }): RotateContext => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
...context,
rotationOrigin: (event as MouseClickEvent).worldMouseLocation,
};
},
),
}),
[RotateAction.RECORD_ROTATION_ANGLE_START_POINT]: assign(
({ context, event }): RotateContext => {
return {
...context,
angleStartPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
}
),
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(
({ context }): RotateContext => {
[RotateAction.COPY_SELECTION_BEFORE_ROTATE]: assign(({ context }): RotateContext => {
const selectedEntities = getSelectedEntities();
// Rotate the selected entities to the ghost helper entities, so they are drawn on the canvas, but do not interact with the snap points / angle guides
@@ -242,53 +231,46 @@ export const rotateToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before rotating them, so we can restore them when the user cancels the rotate action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
};
},
),
}),
[RotateAction.DRAW_TEMP_ROTATE_ENTITIES]: ({ context, event }) => {
if (!context.rotationOrigin || !context.angleStartPoint) {
throw new Error(
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point',
'[ROTATE] Calling draw temp rotate entities without a base start point or base end point'
);
}
const angleEndpoint = (
event as DrawEvent
).drawController.getWorldMouseLocation();
const angleEndpoint = (event as DrawEvent).drawController.getWorldMouseLocation();
// Draw all selected entities according to rotate vector, so the user gets visual feedback of where the entities will be end up after rotating
const rotatedEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
context.originalSelectedEntities.map((entity) => entity.clone())
);
rotateEntities(
rotatedEntities,
context.rotationOrigin,
context.angleStartPoint,
angleEndpoint,
angleEndpoint
);
setGhostHelperEntities(rotatedEntities);
},
[RotateAction.ROTATE_SELECTION]: ({ context, event }) => {
if (!context.rotationOrigin || !context.angleStartPoint) {
throw new Error(
'[ROTATE] Calling rotate selection without some rotate vector endpoints',
);
throw new Error('[ROTATE] Calling rotate selection without some rotate vector endpoints');
}
const angleEndpoint = (event as MouseClickEvent).worldMouseLocation;
// Rotate the entities one final time
const rotatedEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
context.originalSelectedEntities.map((entity) => entity.clone())
);
rotateEntities(
rotatedEntities,
context.rotationOrigin,
context.angleStartPoint,
angleEndpoint,
angleEndpoint
);
// Switch the rotated entities back from the ghost helper entities to the real entities
@@ -306,8 +288,7 @@ export const rotateToolStateMachine = createMachine(
originalSelectedEntities: [],
};
}),
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(
({ context }): RotateContext => {
[RotateAction.RESTORE_ORIGINAL_ENTITIES]: assign(({ context }): RotateContext => {
addEntities(context.originalSelectedEntities, false); // This should already be the last state on the undo stack
setGhostHelperEntities([]);
setSelectedEntityIds([]);
@@ -317,9 +298,8 @@ export const rotateToolStateMachine = createMachine(
angleStartPoint: null,
originalSelectedEntities: [],
};
},
),
}),
...selectToolStateMachine.implementations.actions,
},
},
}
);
@@ -10,7 +10,7 @@ import {
setShouldDrawHelpers,
} from '../state';
import { Tool } from '../tools';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { assign, createMachine, sendTo } from 'xstate';
import { selectToolStateMachine } from './select-tool';
import type { Entity } from '../entities/Entity';
@@ -122,10 +122,7 @@ export const scaleToolStateMachine = createMachine(
}),
},
ESC: {
actions: [
ScaleAction.DESELECT_ENTITIES,
ScaleAction.INIT_SCALE_TOOL,
],
actions: [ScaleAction.DESELECT_ENTITIES, ScaleAction.INIT_SCALE_TOOL],
},
ENTER: {
// Forward the event to the select tool
@@ -189,10 +186,7 @@ export const scaleToolStateMachine = createMachine(
actions: [ScaleAction.DRAW_TEMP_SCALE_ENTITIES],
},
MOUSE_CLICK: {
actions: [
ScaleAction.SCALE_SELECTION,
ScaleAction.DESELECT_ENTITIES,
],
actions: [ScaleAction.SCALE_SELECTION, ScaleAction.DESELECT_ENTITIES],
target: ScaleState.WAITING_FOR_SELECTION,
},
ESC: {
@@ -213,25 +207,19 @@ export const scaleToolStateMachine = createMachine(
[ScaleAction.ENABLE_HELPERS]: () => {
setShouldDrawHelpers(true);
},
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(
({ context, event }) => {
setAngleGuideOriginPoint(
(event as MouseClickEvent).worldMouseLocation,
);
[ScaleAction.RECORD_BASE_VECTOR_START_POINT]: assign(({ context, event }) => {
setAngleGuideOriginPoint((event as MouseClickEvent).worldMouseLocation);
return {
...context,
baseVectorStartPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
),
[ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(
({ context, event }) => {
}),
[ScaleAction.RECORD_BASE_VECTOR_END_POINT]: assign(({ context, event }) => {
return {
...context,
baseVectorEndPoint: (event as MouseClickEvent).worldMouseLocation,
};
},
),
}),
[ScaleAction.COPY_SELECTION_BEFORE_SCALE]: assign(({ context }) => {
const selectedEntities = getSelectedEntities();
@@ -246,53 +234,46 @@ export const scaleToolStateMachine = createMachine(
return {
...context,
// Make a copy of the selected entities before scaling them, so we can restore them when the user cancels the scale action
originalSelectedEntities: compact(
selectedEntities.map(entity => entity.clone()),
),
originalSelectedEntities: compact(selectedEntities.map((entity) => entity.clone())),
};
}),
[ScaleAction.DRAW_TEMP_SCALE_ENTITIES]: ({ context, event }) => {
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
throw new Error(
'[SCALE] Calling draw temp scale entities without a base start point or base end point',
'[SCALE] Calling draw temp scale entities without a base start point or base end point'
);
}
const scaleVectorEndPointTemp = (
event as DrawEvent
).drawController.getWorldMouseLocation();
const scaleVectorEndPointTemp = (event as DrawEvent).drawController.getWorldMouseLocation();
// Draw all selected entities according to scale vector, so the user gets visual feedback of where the entities will be end up after scaling
const scaledEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
context.originalSelectedEntities.map((entity) => entity.clone())
);
scaleEntities(
scaledEntities,
context.baseVectorStartPoint,
context.baseVectorEndPoint,
scaleVectorEndPointTemp,
scaleVectorEndPointTemp
);
setGhostHelperEntities(scaledEntities);
},
[ScaleAction.SCALE_SELECTION]: ({ context, event }) => {
if (!context.baseVectorStartPoint || !context.baseVectorEndPoint) {
throw new Error(
'[SCALE] Calling scale selection without some scale vector endpoints',
);
throw new Error('[SCALE] Calling scale selection without some scale vector endpoints');
}
const scaleVectorEndPoint = (event as MouseClickEvent)
.worldMouseLocation;
const scaleVectorEndPoint = (event as MouseClickEvent).worldMouseLocation;
// Scale the entities one final time
const scaledEntities = compact(
context.originalSelectedEntities.map(entity => entity.clone()),
context.originalSelectedEntities.map((entity) => entity.clone())
);
scaleEntities(
scaledEntities,
context.baseVectorStartPoint,
context.baseVectorEndPoint,
scaleVectorEndPoint,
scaleVectorEndPoint
);
// Switch the scaled entities back from the ghost helper entities to the real entities
@@ -322,5 +303,5 @@ export const scaleToolStateMachine = createMachine(
}),
...selectToolStateMachine.implementations.actions,
},
},
}
);
@@ -1,9 +1,19 @@
import type { Point } from '@flatten-js/core';
import {getNotSelectedEntities, setEntities, setGhostHelperEntities, setSelectedEntityIds, setShouldDrawHelpers,} from '../state';
import type {DrawEvent, MouseClickEvent, StateEvent, ToolContext,} from './tool.types';
import {
getNotSelectedEntities,
setEntities,
setGhostHelperEntities,
setSelectedEntityIds,
setShouldDrawHelpers,
} from '../state';
import type { DrawEvent, MouseClickEvent, StateEvent, ToolContext } from './tool.types';
import { Tool } from '../tools';
import { assign, createMachine } from 'xstate';
import {drawTempSelectionRectangle, handleFirstSelectionPoint, selectEntitiesInsideRectangle,} from './select-tool.helpers';
import {
drawTempSelectionRectangle,
handleFirstSelectionPoint,
selectEntitiesInsideRectangle,
} from './select-tool.helpers';
export interface SelectContext extends ToolContext {
startPoint: Point | null;
@@ -45,8 +55,7 @@ export const selectToolStateMachine = createMachine(
},
},
[SelectState.WAITING_FOR_FIRST_SELECT_POINT]: {
description:
'Select a line or select the first point of a selection rectangle',
description: 'Select a line or select the first point of a selection rectangle',
meta: {
instructions: 'Select a line or start drawing a selection rectangle',
},
@@ -68,23 +77,19 @@ export const selectToolStateMachine = createMachine(
},
},
[SelectState.CHECK_SELECTION]: {
description:
'Checking to select one line or start drawing a selection rectangle',
description: 'Checking to select one line or start drawing a selection rectangle',
meta: {
instructions:
'Select one line or start drawing a selection rectangle',
instructions: 'Select one line or start drawing a selection rectangle',
},
always: [
{
// User started drawing a selection rectangle
guard: ({ context }: { context: SelectContext }) =>
!!context.startPoint,
guard: ({ context }: { context: SelectContext }) => !!context.startPoint,
target: SelectState.WAITING_FOR_SECOND_SELECT_POINT,
},
{
// User clicked on an entity
guard: ({ context }: { context: SelectContext }) =>
!context.startPoint,
guard: ({ context }: { context: SelectContext }) => !context.startPoint,
target: SelectState.WAITING_FOR_FIRST_SELECT_POINT,
},
],
@@ -122,7 +127,7 @@ export const selectToolStateMachine = createMachine(
HANDLE_FIRST_SELECT_POINT: assign(
({ context, event }: { context: SelectContext; event: StateEvent }) => {
return handleFirstSelectionPoint(context, event as MouseClickEvent);
},
}
),
DRAW_TEMP_SELECTION_RECTANGLE: ({
context,
@@ -133,13 +138,11 @@ export const selectToolStateMachine = createMachine(
}) => {
if (!context.startPoint) {
// assert
throw new Error(
'[SELECT] Calling drawTempSelectionRectangle without startPoint set',
);
throw new Error('[SELECT] Calling drawTempSelectionRectangle without startPoint set');
}
drawTempSelectionRectangle(
context.startPoint as Point,
(event as DrawEvent).drawController.getWorldMouseLocation(),
(event as DrawEvent).drawController.getWorldMouseLocation()
);
},
SELECT_ENTITIES_INSIDE_RECTANGLE: ({
@@ -151,14 +154,12 @@ export const selectToolStateMachine = createMachine(
}) => {
if (!context.startPoint) {
//
throw new Error(
'[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point',
);
throw new Error('[SELECT] calling SELECT_ENTITIES_INSIDE_RECTANGLE without start point');
}
selectEntitiesInsideRectangle(
context.startPoint,
(event as MouseClickEvent).worldMouseLocation,
(event as MouseClickEvent).holdingCtrl,
(event as MouseClickEvent).holdingCtrl
// (event as MouseClickEvent).holdingShift,
);
setGhostHelperEntities([]);
@@ -176,5 +177,5 @@ export const selectToolStateMachine = createMachine(
};
}),
},
},
}
);
@@ -21,11 +21,7 @@ export interface TypedCommandEvent {
export interface ToolHandler {
handleToolActivate(): void;
handleToolClick(
worldMouseLocation: Point,
holdingCtrl: boolean,
holdingShift: boolean,
): void;
handleToolClick(worldMouseLocation: Point, holdingCtrl: boolean, holdingShift: boolean): void;
handleToolTypedCommand(command: string): void;
}
@@ -1,6 +1,10 @@
/** 클립보드 명령 — 잘라내기·복사·붙여넣기 (조사표 3절 클립보드 패널) */
import { toast } from 'react-toastify';
import { copyToClipboard, hasClipboardContent, pasteFromClipboard } from '../../helpers/cad-clipboard';
import {
copyToClipboard,
hasClipboardContent,
pasteFromClipboard,
} from '../../helpers/cad-clipboard';
import {
addEntities,
deleteEntities,
@@ -1,6 +1,10 @@
/** 특성 명령 — 투명도와 특성 팔레트 열기 (조사표 3절 특성 패널) */
import { toast } from 'react-toastify';
import { openInspector, setQuickPropertiesVisible, isQuickPropertiesVisible } from '../../components/ui-state';
import {
openInspector,
setQuickPropertiesVisible,
isQuickPropertiesVisible,
} from '../../components/ui-state';
import { getEntities, setEntities, setSelectedEntityIds } from '../../state';
import { Tool } from '../../tools';
import { createSequenceTool } from '../factories/sequence-tool';
@@ -70,7 +74,11 @@ export const lineTypeToolStateMachine = createSequenceTool({
helpers: false,
steps: [
{ kind: 'selection', instructions: '선종류를 바꿀 객체를 선택한 뒤 ENTER.' },
{ kind: 'text', instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).', defaultValue: '실선' },
{
kind: 'text',
instructions: '선종류를 입력하십시오 (실선·파선·1점쇄선·점선).',
defaultValue: '실선',
},
],
commit: (input) => {
const key = input.text(1).trim();
@@ -22,8 +22,7 @@ test('Draw circle', async () => {
const circleEntity = entities[0];
expect(circleEntity.getType()).toBe(EntityName.Circle);
const circleJson =
(await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
const circleJson = (await circleEntity.toJson()) as JsonEntity<CircleJsonData>;
expect(circleJson.lineColor).toBe('#fff');
expect(circleJson.lineWidth).toBe(1);
@@ -33,7 +32,5 @@ test('Draw circle', async () => {
const diffX = 424 - 257;
const diffY = 366 - 325;
expect(circleJson.shapeData.radius).toBe(
Math.sqrt(diffX * diffX + diffY * diffY),
);
expect(circleJson.shapeData.radius).toBe(Math.sqrt(diffX * diffX + diffY * diffY));
});
@@ -2,7 +2,12 @@ import {Point} from '@flatten-js/core';
import { Actor } from 'xstate';
import type { ScreenCanvasDrawController } from '../../src/drawControllers/screenCanvas.drawController';
import { InputController } from '../../src/inputController/input-controller';
import {setActiveToolActor, setEntities, setInputController, setScreenCanvasDrawController,} from '../../src/state';
import {
setActiveToolActor,
setEntities,
setInputController,
setScreenCanvasDrawController,
} from '../../src/state';
import { Tool } from '../../src/tools';
import { TOOL_STATE_MACHINES } from '../../src/commands/registry';
import { ScreenCanvasDrawController as ScreenCanvasDrawControllerMock } from '../mocks/drawControllers/screenCanvas.drawController';
@@ -1,4 +1,4 @@
import {TOOLBAR_WIDTH} from "../../src/App.consts";
import { TOOLBAR_WIDTH } from '../../src/App.consts';
export const CANVAS_WIDTH = 1920 - TOOLBAR_WIDTH;
export const CANVAS_HEIGHT = 1080;
@@ -35,11 +35,9 @@ export class ScreenCanvasDrawController implements DrawController {
constructor(
private context: CanvasRenderingContext2D | null,
private canvasSize: Point,
private canvasSize: Point
) {
this.worldMouseLocation = this.targetToWorld(
new Point(canvasSize.x / 2, canvasSize.y / 2),
);
this.worldMouseLocation = this.targetToWorld(new Point(canvasSize.x / 2, canvasSize.y / 2));
this.setScreenOffset(new Point(0, 0)); // User expects mathematical coordinates, where y axis goes up, but canvas y axis goes down
}
@@ -81,7 +79,7 @@ export class ScreenCanvasDrawController implements DrawController {
public panScreen(screenOffsetX: number, screenOffsetY: number) {
this.screenOffset = new Point(
this.screenOffset.x - screenOffsetX / this.screenScale,
this.screenOffset.y - screenOffsetY / this.screenScale,
this.screenOffset.y - screenOffsetY / this.screenScale
);
}
@@ -93,8 +91,7 @@ export class ScreenCanvasDrawController implements DrawController {
public zoomScreen(deltaY: number) {
const worldMouseLocationBeforeZoom = this.getWorldMouseLocation();
const newScreenScale =
this.getScreenScale() *
(1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
this.getScreenScale() * (1 - MOUSE_ZOOM_MULTIPLIER * (deltaY / Math.abs(deltaY)));
this.setScreenScale(newScreenScale);
// now get the location of the cursor in world space again
@@ -105,12 +102,8 @@ export class ScreenCanvasDrawController implements DrawController {
// Adjust the screen offset to maintain the cursor position
this.screenOffset = new Point(
this.screenOffset.x +
(worldMouseLocationBeforeZoom.x -
worldMouseLocationAfterZoom.x),
this.screenOffset.y +
(worldMouseLocationBeforeZoom.y -
worldMouseLocationAfterZoom.y),
this.screenOffset.x + (worldMouseLocationBeforeZoom.x - worldMouseLocationAfterZoom.x),
this.screenOffset.y + (worldMouseLocationBeforeZoom.y - worldMouseLocationAfterZoom.y)
);
}
@@ -124,15 +117,15 @@ export class ScreenCanvasDrawController implements DrawController {
this.screenOffset.x,
this.screenOffset.x + this.canvasSize.x / this.screenScale,
0,
this.canvasSize.x,
this.canvasSize.x
),
mapNumberRange(
worldCoordinate.y,
this.screenOffset.y + this.canvasSize.y / this.screenScale, // inverted since world origin is bottom left and screen origin is top left
this.screenOffset.y,
0,
this.canvasSize.y,
),
this.canvasSize.y
)
);
}
@@ -160,15 +153,15 @@ export class ScreenCanvasDrawController implements DrawController {
0,
this.canvasSize.x,
this.screenOffset.x,
this.screenOffset.x + this.canvasSize.x / this.screenScale,
this.screenOffset.x + this.canvasSize.x / this.screenScale
),
mapNumberRange(
screenCoordinate.y,
0,
this.canvasSize.y,
this.screenOffset.y,
this.screenOffset.y + this.canvasSize.y / this.screenScale,
),
this.screenOffset.y + this.canvasSize.y / this.screenScale
)
);
}
@@ -181,7 +174,7 @@ export class ScreenCanvasDrawController implements DrawController {
isSelected: boolean,
color: string,
lineWidth: number,
dash: number[] = [],
dash: number[] = []
) {}
public setFillStyles(fillColor: string) {}
@@ -200,10 +193,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param screenStartPoint
* @param screenEndPoint
*/
public drawLineScreen(
screenStartPoint: Point,
screenEndPoint: Point,
): void {}
public drawLineScreen(screenStartPoint: Point, screenEndPoint: Point): void {}
/**
* Draw an arc (segment of a circle) or a circle if startAngle = 0 and endAngle = 2PI
@@ -218,7 +208,7 @@ export class ScreenCanvasDrawController implements DrawController {
radius: number,
startAngle: number,
endAngle: number,
counterClockWise: boolean,
counterClockWise: boolean
) {}
public drawArcScreen(
@@ -226,7 +216,7 @@ export class ScreenCanvasDrawController implements DrawController {
screenRadius: number,
startAngle: number,
endAngle: number,
counterClockWise: boolean,
counterClockWise: boolean
) {}
/**
@@ -245,7 +235,7 @@ export class ScreenCanvasDrawController implements DrawController {
textColor: string;
fontSize: number;
fontFamily: string;
}> = {},
}> = {}
): void {}
/**
@@ -264,7 +254,7 @@ export class ScreenCanvasDrawController implements DrawController {
textColor: string;
fontSize: number;
fontFamily: string;
}> = {},
}> = {}
): void {}
/**
@@ -282,16 +272,10 @@ export class ScreenCanvasDrawController implements DrawController {
yMin: number,
width: number,
height: number,
angle: number,
angle: number
): void {}
public fillRect(
xMin: number,
yMin: number,
width: number,
height: number,
color: string,
) {}
public fillRect(xMin: number, yMin: number, width: number, height: number, color: string) {}
/**
* Fill rectangle with color, but interpret the provided coordinates as screen coordinates
@@ -301,13 +285,7 @@ export class ScreenCanvasDrawController implements DrawController {
* @param height
* @param color
*/
public fillRectScreen(
xMin: number,
yMin: number,
width: number,
height: number,
color: string,
) {}
public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) {}
/**
* Fill polygon with color
@@ -7,7 +7,7 @@ import {pointDistance} from '../../../src/helpers/distance-between-points';
import { getEntities } from '../../../src/state';
import { initApplication } from '../../helpers/init-application';
import { replayRecording } from '../../helpers/replay-recording';
import {CANVAS_HEIGHT} from "../../helpers/tests.consts";
import { CANVAS_HEIGHT } from '../../helpers/tests.consts';
import eraserRecording from './eraser.recording.json';
test('Draw circle and line and erase part of circle', async () => {
@@ -5,12 +5,14 @@
실행 가상환경(venv) 터미널에서 다음 라이브러리를 설치해 주세요:
pip install geopandas pyogrio
"""
import sys
from pathlib import Path
import time
try:
import geopandas as gpd
# fiona 대신 현재 설치된 고속 pyogrio 엔진을 검증 및 사용합니다.
import pyogrio
except ImportError:
@@ -19,12 +21,15 @@ except ImportError:
print(">>> pip install geopandas pyogrio")
sys.exit(1)
def convert_shp_to_gpkg():
current_dir = Path(__file__).resolve().parent
shp_files = sorted(list(current_dir.glob("TN_CTRLN*.shp")))
if not shp_files:
print(f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다.")
print(
f"[경고] {current_dir} 경로에서 'TN_CTRLN'으로 시작하는 .shp 파일을 찾을 수 없습니다."
)
return
gpkg_output_path = current_dir / "national_contours.gpkg"
@@ -53,7 +58,9 @@ def convert_shp_to_gpkg():
# GeoPackage 파일로 쓰기 (고속 pyogrio 엔진 및 spatial_index 생성 활성화)
print(" -> GeoPackage 초기 생성 및 쓰기 중...")
gdf.to_file(gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio")
gdf.to_file(
gpkg_output_path, layer="contours", driver="GPKG", spatial_index=True, engine="pyogrio"
)
print(f" -> 완료 (레코드 수: {len(gdf)}개)")
except Exception as e:
@@ -73,12 +80,21 @@ def convert_shp_to_gpkg():
gdf_append["elevation"] = gdf_append[elev_col].astype(float)
break
keep_cols = ["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"]
keep_cols = (
["geometry", "elevation"] if "elevation" in gdf_append.columns else ["geometry"]
)
gdf_append = gdf_append[keep_cols]
# 기존 gpkg 파일에 이어쓰기 (append mode, pyogrio 엔진 사용)
print(" -> GeoPackage에 데이터 이어붙이는 중...")
gdf_append.to_file(gpkg_output_path, layer="contours", driver="GPKG", mode="a", spatial_index=True, engine="pyogrio")
gdf_append.to_file(
gpkg_output_path,
layer="contours",
driver="GPKG",
mode="a",
spatial_index=True,
engine="pyogrio",
)
print(f" -> 완료 (레코드 수: {len(gdf_append)}개)")
except Exception as e:
@@ -92,5 +108,6 @@ def convert_shp_to_gpkg():
print(f"★ 파일 위치: {gpkg_output_path}")
print("==================================================")
if __name__ == "__main__":
convert_shp_to_gpkg()
@@ -1,24 +1,32 @@
# -*- coding: utf-8 -*-
"""lawapi.json에서 정확명 매칭 행을 뽑아 시행일/개정일 맵 생성 + 수동 보정."""
import json
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
OUT = Path(str(DATA_DIR))
api = json.load(open(OUT / "lawapi.json", encoding="utf-8"))
@@ -29,26 +37,35 @@ Q = {
"산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙",
"산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령",
"산림보호법": "산림보호법", "산지관리법": "산지관리법",
"자연환경보전법": "자연환경보전법", "자연재해대책": "자연재해대책",
"산림보호법": "산림보호법",
"산지관리": "산지관리",
"자연환경보전법": "자연환경보전법",
"자연재해대책법": "자연재해대책법",
"환경영향평가법": "환경영향평가법",
"보조금 관리에 관한 법률": "보조금 관리에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로": "도로법", "농어촌도로정비": "농어촌도로 정비",
"도로법": "도로",
"농어촌도로정비법": "농어촌도로 정비법",
"국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률",
"도로명주소법": "도로명주소법", "도로명주소법 시행령": "도로명주소법 시행령",
"도로명주소법": "도로명주소법",
"도로명주소법 시행령": "도로명주소법 시행령",
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법": "산업안전보건법", "산업재해보상보험법": "산업재해보상보험법",
"산업안전보건법": "산업안전보건법",
"산업재해보상보험법": "산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령": "고용보험법 시행령",
"국민건강보험법": "국민건강보험법", "국민연금법": "국민연금법",
"노인장기요양보험법": "노인장기요양보험법", "노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
"부가가치세법": "부가가치세법", "근로기준": "근로기준",
"산업표준화법": "산업표준화법", "전자서명법": "전자서명법",
"국민건강보험법": "국민건강보험법",
"국민연금법": "국민연금법",
"노인장기요양보험": "노인장기요양보험",
"노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
"부가가치세법": "부가가치세법",
"근로기준법": "근로기준법",
"산업표준화법": "산업표준화법",
"전자서명법": "전자서명법",
"공동주택관리법": "공동주택관리법",
"임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정",
@@ -71,61 +88,226 @@ m = {}
for name, q in Q.items():
rows = api.get(q, {}).get("rows", [])
want = EXACT.get(name, name)
hit = next((r for r in rows if r[""] == want), None) or next((r for r in rows if r[""].startswith(want)), None)
hit = next((r for r in rows if r[""] == want), None) or next(
(r for r in rows if r[""].startswith(want)), None
)
if not hit:
print("!! 미매칭", name, "|", [r[""] for r in rows][:3])
continue
m[name] = {"시행": hit["시행"], "개정": hit["공포"], "구분": hit["제개정"],
"호수": hit["번호"], "종류": hit["종류"], "출처": SRC_LAW, "실명": hit[""]}
m[name] = {
"시행": hit["시행"],
"개정": hit["공포"],
"구분": hit["제개정"],
"호수": hit["번호"],
"종류": hit["종류"],
"출처": SRC_LAW,
"실명": hit[""],
}
# ── 2·3차 조회 결과 및 별표·비수록 항목 수동 등록 ──
MANUAL = {
"국가균형발전특별법": ("2026.07.01", "2026.03.05", "일부개정", "21447", "법률", SRC_LAW,
"폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」"),
"수치지도 작성 작업규칙": ("2015.06.04", "2015.06.04", "일부개정", "00209", "국토교통부령", SRC_LAW, ""),
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ("2025.11.28", "2025.11.27", "일부개정", "35875", "대통령령", SRC_LAW,
"「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)"),
"임도 품셈 적용기준 / 임도표준품셈": ("2026.01.01", "2025.11.26", "전부개정", "2025-82", "고시", SRC_LAW,
"현 「산림사업 표준품셈」(산림청 고시)"),
"건설공사 감독자 업무 지침": ("2026.07.08", "2026.07.08", "일부개정", "2026-360", "고시", SRC_LAW,
"현 「건설공사 사업관리방식 검토기준 및 업무수행지침」"),
"공사장의 비산분진 발생원 시설관리기준": ("2026.07.15", "2026.07.15", "일부개정", "00049", "환경부령", SRC_LAW,
"「대기환경보전법 시행규칙」 별표에 수록"),
"국가지점번호판 규격 등 고시": ("2024.07.05", "2024.07.05", "제정", "2024-56", "고시", SRC_LAW,
"「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」"),
"국가지점번호 부여기준 및 방법": ("2012.12.18", "2012.12.12", "제정", "2012-55", "고시", SRC_LAW,
"「국가지점번호 기준점 고시」"),
"입찰유의서(계약예규)": ("2025.12.31", "2025.12.31", "일부개정", "", "계약예규", SRC_LAW,
"「(계약예규) 공사입찰유의서」"),
"콘크리트 표준시방서": ("2025.01.05", "2024.12.30", "일부개정", "2025-879", "고시", "국가건설기준센터",
"KCS 14 20 00 (국토교통부 고시로도 수록)"),
"도로공사 표준시방서": ("2023.01.12", "2023.01.06", "일부개정", "2023-907", "고시", "국가건설기준센터",
"KCS 44 00 00"),
"토목공사 표준시방서": ("2023.01.25", "2023.01.19", "일부개정", "2023-48", "고시", "국가건설기준센터",
"현 KCS 10 00 00 공통공사 표준시방서로 재편"),
"건설공사 표준시방서": ("2018.08.09", "2018.08.03", "제정", "2018-468", "고시", "국가건설기준센터",
"건설기준 코드(KDS/KCS) 체계로 통합"),
"건설공사 비탈면 표준시방서": ("", "", "", "", "", "국가건설기준센터",
"KCS 11 70 00 비탈면 (법령정보센터 미수록)"),
"임도시설공사 표준시방서": ("", "", "", "", "", "산림청",
"법령정보센터·건설기준센터 모두 미수록"),
"산림관리기반시설의 설계 및 시설기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표2에 수록"),
"산림관리기반시설의 범위 및 기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표1에 수록"),
"산림관리기반시설의 타당성평가 항목별 기준 및 방법": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표1의2에 수록"),
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ("", "", "", "", "지침", "산림청",
"법령정보센터 미수록"),
"국가균형발전특별법": (
"2026.07.01",
"2026.03.05",
"일부개정",
"21447",
"법률",
SRC_LAW,
"폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」",
),
"수치지도 작성 작업규칙": (
"2015.06.04",
"2015.06.04",
"일부개정",
"00209",
"국토교통부령",
SRC_LAW,
"",
),
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정": (
"2025.11.28",
"2025.11.27",
"일부개정",
"35875",
"대통령령",
SRC_LAW,
"「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)",
),
"임도 품셈 적용기준 / 임도표준품셈": (
"2026.01.01",
"2025.11.26",
"전부개정",
"2025-82",
"고시",
SRC_LAW,
"「산림사업 표준품셈」(산림청 고시)",
),
"건설공사 감독자 업무 지침": (
"2026.07.08",
"2026.07.08",
"일부개정",
"2026-360",
"고시",
SRC_LAW,
"현 「건설공사 사업관리방식 검토기준 및 업무수행지침」",
),
"공사장의 비산분진 발생원 시설관리기준": (
"2026.07.15",
"2026.07.15",
"일부개정",
"00049",
"환경부령",
SRC_LAW,
"「대기환경보전법 시행규칙」 별표에 수록",
),
"국가지점번호판 규격 등 고시": (
"2024.07.05",
"2024.07.05",
"제정",
"2024-56",
"고시",
SRC_LAW,
"「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」",
),
"국가지점번호 부여기준 및 방법": (
"2012.12.18",
"2012.12.12",
"제정",
"2012-55",
"고시",
SRC_LAW,
"「국가지점번호 기준점 고시」",
),
"입찰유의서(계약예규)": (
"2025.12.31",
"2025.12.31",
"일부개정",
"",
"계약예규",
SRC_LAW,
"「(계약예규) 공사입찰유의서」",
),
"콘크리트 표준시방서": (
"2025.01.05",
"2024.12.30",
"일부개정",
"2025-879",
"고시",
"국가건설기준센터",
"KCS 14 20 00 (국토교통부 고시로도 수록)",
),
"도로공사 표준시방서": (
"2023.01.12",
"2023.01.06",
"일부개정",
"2023-907",
"고시",
"국가건설기준센터",
"KCS 44 00 00",
),
"토목공사 표준시방서": (
"2023.01.25",
"2023.01.19",
"일부개정",
"2023-48",
"고시",
"국가건설기준센터",
"현 KCS 10 00 00 공통공사 표준시방서로 재편",
),
"건설공사 표준시방서": (
"2018.08.09",
"2018.08.03",
"제정",
"2018-468",
"고시",
"국가건설기준센터",
"건설기준 코드(KDS/KCS) 체계로 통합",
),
"건설공사 비탈면 표준시방서": (
"",
"",
"",
"",
"",
"국가건설기준센터",
"KCS 11 70 00 비탈면 (법령정보센터 미수록)",
),
"임도시설공사 표준시방서": (
"",
"",
"",
"",
"",
"산림청",
"법령정보센터·건설기준센터 모두 미수록",
),
"산림관리기반시설의 설계 및 시설기준": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표2에 수록",
),
"산림관리기반시설의 범위 및 기준": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표1에 수록",
),
"산림관리기반시설의 타당성평가 항목별 기준 및 방법": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표1의2에 수록",
),
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": (
"",
"",
"",
"",
"지침",
"산림청",
"법령정보센터 미수록",
),
"중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"),
"예산편성기준": ("", "", "", "", "지침", "기획재정부",
"「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포"),
"토목공사원가계산 제비율 적용기준": ("", "", "", "", "기준", "조달청",
"법령정보센터 미수록, 조달청 연간 발표"),
"예산편성기준": (
"",
"",
"",
"",
"지침",
"기획재정부",
"「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포",
),
"토목공사원가계산 제비율 적용기준": (
"",
"",
"",
"",
"기준",
"조달청",
"법령정보센터 미수록, 조달청 연간 발표",
),
}
for k, v in MANUAL.items():
m[k] = {"시행": v[0], "개정": v[1], "구분": v[2], "호수": v[3],
"종류": v[4], "출처": v[5], "실명": "", "추가": v[6]}
m[k] = {
"시행": v[0],
"개정": v[1],
"구분": v[2],
"호수": v[3],
"종류": v[4],
"출처": v[5],
"실명": "",
"추가": v[6],
}
json.dump(m, open(OUT / "srcmap.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"매핑 {len(m)}건 생성")
@@ -1,30 +1,42 @@
# -*- coding: utf-8 -*-
"""국가법령정보센터 Open API로 목록 항목의 보유 여부·시행일·개정일 조회."""
import json, re, time, urllib.parse, urllib.request
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
import xml.etree.ElementTree as ET
OUT = Path(str(DATA_DIR))
def api(target, query, display=5):
url = ("https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML"
f"&target={target}&display={display}&query=" + urllib.parse.quote(query))
url = (
"https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML"
f"&target={target}&display={display}&query=" + urllib.parse.quote(query)
)
for _ in range(3):
try:
with urllib.request.urlopen(url, timeout=25) as r:
@@ -33,6 +45,7 @@ def api(target, query, display=5):
time.sleep(1.2)
return None
def txt(node, *names):
for n in names:
el = node.find(n)
@@ -40,27 +53,49 @@ def txt(node, *names):
return el.text.strip()
return ""
def ymd(s):
s = re.sub(r"\D", "", s or "")
return f"{s[:4]}.{s[4:6]}.{s[6:8]}" if len(s) == 8 else ""
LAWS = [
"산림자원의 조성 및 관리에 관한 법률", "산림자원의 조성 및 관리에 관한 법률 시행령",
"산림자원의 조성 및 관리에 관한 법률 시행규칙", "산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행", "산림보호법", "산지관리법",
"자연환경보전법", "자연재해대책법", "환경영향평가법", "국가균형발전특별법",
"보조금 관리에 관한 법률", "국가를 당사자로 하는 계약에 관한 법률",
"산림자원의 조성 및 관리에 관한 법률",
"산림자원의 조성 및 관리에 관한 법률 시행",
"산림자원의 조성 및 관리에 관한 법률 시행규칙",
"산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령",
"산림보호법",
"산지관리법",
"자연환경보전법",
"자연재해대책법",
"환경영향평가법",
"국가균형발전특별법",
"보조금 관리에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로법", "농어촌도로 정비법", "국토의 계획 및 이용에 관한 법률",
"도로명주소법", "도로명주소법 시행령", "공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법", "산업재해보상보험법",
"도로법",
"농어촌도로 정비법",
"국토의 계획 및 이용에 관한 법률",
"도로명주소법",
"도로명주소법 시행령",
"공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법",
"산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령", "국민건강보험법", "국민연금법", "노인장기요양보험법",
"노인장기요양보험법 시행령", "부가가치세법", "근로기준법", "산업표준화",
"전자서명법", "공동주택관리",
"고용보험법 시행령",
"국민건강보험",
"국민연금",
"노인장기요양보험법",
"노인장기요양보험법 시행령",
"부가가치세법",
"근로기준법",
"산업표준화법",
"전자서명법",
"공동주택관리법",
]
RULES = [
@@ -84,7 +119,9 @@ RULES = [
"수치지도 작성 작업규칙",
"비산분진 발생원 시설관리기준",
"건설공사 감독자 업무 지침",
"콘크리트 표준시방서", "도로공사 표준시방서", "임도시설공사 표준시방서",
"콘크리트 표준시방서",
"도로공사 표준시방서",
"임도시설공사 표준시방서",
]
res = {}
@@ -94,7 +131,8 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R
rows = []
if root is not None:
for node in root.findall("law") + root.findall("admrul"):
rows.append({
rows.append(
{
"": txt(node, "법령명한글", "행정규칙명"),
"약칭": txt(node, "법령약칭명"),
"종류": txt(node, "법령구분명", "행정규칙종류"),
@@ -103,9 +141,13 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R
"시행": ymd(txt(node, "시행일자")),
"제개정": txt(node, "제개정구분명", "제개정구분코드"),
"번호": txt(node, "공포번호", "발령번호"),
})
}
)
res[q] = {"grp": grp, "target": target, "rows": rows}
print(f"[{grp}] {q} -> {len(rows)}" + (f" | {rows[0]['']} 시행 {rows[0]['시행']}" if rows else ""))
print(
f"[{grp}] {q} -> {len(rows)}"
+ (f" | {rows[0]['']} 시행 {rows[0]['시행']}" if rows else "")
)
time.sleep(0.35)
json.dump(res, open(OUT / "lawapi.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -19,6 +19,7 @@
값형(4~6) 원본 스냅샷을 폴더에 보존하고, 프로그램용 데이터셋은 여기서 별도 추출해 구성한다
(2026-08-14 사용자 결정). 표준시장단가는 수집 제외 (100 미만 공사 미적용·품셈 방식과 별도 트랙).
"""
import csv
import json
import os
@@ -36,18 +37,30 @@ UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
# ── 반기 갱신 대상 URL (2026 상반기/2026년판 기준) ──
DOC_SOURCES = [
# (폴더, 파일명, URL, referer)
("노임단가_건설업_대한건설협회", "2026상반기_건설업_임금실태조사_대한건설협회.pdf",
(
"노임단가_건설업_대한건설협회",
"2026상반기_건설업_임금실태조사_대한건설협회.pdf",
"https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf",
"https://www.cak.or.kr/lay1/S1T16C41/sublink.do"),
("노임단가_제조업_중소기업중앙회", "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf",
"https://www.cak.or.kr/lay1/S1T16C41/sublink.do",
),
(
"노임단가_제조업_중소기업중앙회",
"2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf",
None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용
"https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372"),
("건설공사_표준품셈", "2026년_건설공사_표준품셈.pdf",
"https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372",
),
(
"건설공사_표준품셈",
"2026년_건설공사_표준품셈.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"),
("건설공사_표준품셈", "2026년_건설공사_표준품셈_개정사항.pdf",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1",
),
(
"건설공사_표준품셈",
"2026년_건설공사_표준품셈_개정사항.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"),
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1",
),
]
G2B_OPS = [
@@ -93,7 +106,11 @@ def collect_docs():
continue
url = "https://www.kbiz.or.kr" + links[0].replace("&amp;", "&")
data = fetch(url, referer)
if not data[:4] == b"%PDF" and not data[:4] == b"PK\x03\x04" and not data[:8].startswith(b"\xd0\xcf\x11\xe0"):
if (
not data[:4] == b"%PDF"
and not data[:4] == b"PK\x03\x04"
and not data[:8].startswith(b"\xd0\xcf\x11\xe0")
):
print(f"FAIL {fname}: PDF/HWP 아님 ({data[:8].hex()}) — URL 갱신 필요")
continue
dest.write_bytes(data)
@@ -109,13 +126,22 @@ def collect_values():
ecos = read_key(r"ECOS[^`]*\n- 인증키: `([^`]+)`", "ECOS")
year = today[:4]
rows = []
for code, name in [("0000001", "원/미국달러"), ("0000002", "원/일본엔100"),
("0000003", "원/유로"), ("0000012", "원/영국파운드"), ("0000053", "원/위안")]:
url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/"
f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}")
for code, name in [
("0000001", "원/미국달러"),
("0000002", "원/일본엔100"),
("0000003", "원/유로"),
("0000012", "원/영국파운드"),
("0000053", "원/위안"),
]:
url = (
f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/"
f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}"
)
j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace"))
for r in j.get("StatisticSearch", {}).get("row", []):
rows.append({"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]})
rows.append(
{"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]}
)
out = BASE / "환율_한국은행ECOS" / f"환율_일별_{year}0101_{today}.csv"
out.parent.mkdir(exist_ok=True)
with open(out, "w", newline="", encoding="utf-8-sig") as f:
@@ -127,11 +153,18 @@ def collect_values():
# 유가: 오늘 전국 평균 + 유종별 최근 7일
opinet = read_key(r"오피넷[^`]*`([^`]+)`", "오피넷")
data = {"수집일": today}
j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60))
j = json.loads(
fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60)
)
data["전국평균"] = j.get("RESULT", {}).get("OIL", [])
data["최근7일"] = {}
for prod in ["B027", "D047"]: # 휘발유, 자동차용경유
j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", timeout=60))
j = json.loads(
fetch(
f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}",
timeout=60,
)
)
data["최근7일"][prod] = j.get("RESULT", {}).get("OIL", [])
out = BASE / "유가_오피넷" / f"유가_전국평균_{today}.json"
out.parent.mkdir(exist_ok=True)
@@ -146,8 +179,10 @@ def collect_snapshot(outdir=None):
for op, label in G2B_OPS:
page, got, total = 1, 0, 1
while got < total:
url = (f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}"
f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json")
url = (
f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}"
f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json"
)
j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace"))
body = j.get("response", {}).get("body", {})
total = int(body.get("totalCount") or 0)
@@ -173,7 +208,8 @@ def collect_snapshot(outdir=None):
w.writeheader()
w.writerows(rows)
(outp / f"나라장터_시설공통자재_{today}.json").write_text(
json.dumps(rows, ensure_ascii=False), encoding="utf-8")
json.dumps(rows, ensure_ascii=False), encoding="utf-8"
)
print(f"saved {out.name} ({len(rows):,} rows, +json)")
@@ -4,6 +4,7 @@
- zip CP949(EUC-KR) 파일명 mojibake를 복원해 `첨부/[zip]<이름>/` 해제
- 내부 HWP/HWPX md 변환(hwpx_text.to_md / hwp5_to_md)
"""
import re, sys, zipfile
from pathlib import Path
@@ -12,6 +13,7 @@ import hwpx_text
ROOT = Path(__file__).resolve().parent.parent
def fixname(n):
"""zip 엔트리명 CP437 mojibake → CP949 복원."""
try:
@@ -19,9 +21,11 @@ def fixname(n):
except Exception:
return n
def safe_part(s):
return re.sub(r'[:*?"<>|]', "_", s).strip()
def extract_one(zip_path):
zf = zipfile.ZipFile(zip_path)
dest = zip_path.parent / ("[압축] " + zip_path.stem)
@@ -38,6 +42,7 @@ def extract_one(zip_path):
n += 1
return dest, n
def convert_dir(folder):
ok = fail = 0
for f in sorted(folder.rglob("*")):
@@ -60,6 +65,7 @@ def convert_dir(folder):
fail += 1
return ok, fail
if __name__ == "__main__":
zips = [Path(a) for a in sys.argv[1:]] or list(ROOT.rglob("첨부/*.zip"))
for z in zips:
@@ -4,6 +4,7 @@
법령 조문의 <img> 안에 있던 박스 드로잉 표가 이미지 로컬화 텍스트로 남는데,
열을 구분하므로 md 표로 복원한다. 표에는 대응 ![그림] 이미 있다.
"""
import re, sys
from pathlib import Path
@@ -12,20 +13,25 @@ BORDER = set("┌┬┐├┼┤└┴┘─━┏┳┓┣╋┫┗┻┛│┃
VBAR = "│┃|"
QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> '
def unq(l):
return QP.sub("", l)
def is_border(l):
s = unq(l).strip()
return bool(s) and all(c in BORDER for c in s) and any(c in "─━┼┬┴┌┐└┘├┤" for c in s)
def is_data(l):
return any(c in "│┃" for c in unq(l))
def split_cells(l):
s = unq(l).strip().strip("│┃")
return [c.strip() for c in re.split(r"[│┃]", s)]
def convert_block(lines):
rows = [split_cells(l) for l in lines if is_data(l)]
rows = [r for r in rows if any(c for c in r)]
@@ -36,12 +42,12 @@ def convert_block(lines):
return None
rows = [r + [""] * (w - len(r)) for r in rows]
esc = lambda c: c.replace("|", "\\|")
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
"|" + "|".join(["---"] * w) + "|"]
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
for r in rows[1:]:
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return out
def fix(md_path):
lines = md_path.read_text(encoding="utf-8").split("\n")
out = []
@@ -51,14 +57,20 @@ def fix(md_path):
while i < n:
if lines[i].lstrip().startswith("```"):
infence = not infence
out.append(lines[i]); i += 1
out.append(lines[i])
i += 1
continue
# 박스표 블록 시작: border 또는 data(│ 포함) 연속 (펜스 밖에서만)
if not infence and (is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))):
if not infence and (
is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))
):
j = i
block = []
while j < n and (is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))):
block.append(lines[j]); j += 1
while j < n and (
is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))
):
block.append(lines[j])
j += 1
data_rows = [b for b in block if is_data(b)]
md = convert_block(block)
# 열이 일정한 진짜 표만 md 표로. 아니면(수식 등) 코드펜스로 정렬 보존.
@@ -75,11 +87,13 @@ def fix(md_path):
changed += 1
i = j
continue
out.append(lines[i]); i += 1
out.append(lines[i])
i += 1
if changed:
md_path.write_text("\n".join(out), encoding="utf-8")
return changed
if __name__ == "__main__":
total = 0
for md in ROOT.rglob("*.md"):
@@ -4,17 +4,25 @@
법령 XML 조문·개정문에 인라인으로 박힌 수식·그림 이미지 처리.
이미지는 명칭 폴더의 pic/ 저장하고 md에서 ../pic 상대참조.
"""
import re, sys, time, urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UA = {"User-Agent": "Mozilla/5.0"}
# src="URL" 형식과 id="flSeq" 형식(내부 이미지) 모두 처리
IMG = re.compile(r'<img\b([^>]*?)/?>')
IMG = re.compile(r"<img\b([^>]*?)/?>")
SRC = re.compile(r'src="([^"]+)"')
IID = re.compile(r'id="(\d+)"')
EXT = {b"\x89PNG": ".png", b"\xff\xd8\xff": ".jpg", b"GIF8": ".gif",
b"BM": ".bmp", b"II*\x00": ".tif", b"MM\x00*": ".tif"}
EXT = {
b"\x89PNG": ".png",
b"\xff\xd8\xff": ".jpg",
b"GIF8": ".gif",
b"BM": ".bmp",
b"II*\x00": ".tif",
b"MM\x00*": ".tif",
}
def sniff(b):
for sig, ext in EXT.items():
@@ -22,6 +30,7 @@ def sniff(b):
return ext
return ".png"
def download(url):
u = url.replace("http://", "https://")
for _ in range(3):
@@ -34,6 +43,7 @@ def download(url):
time.sleep(1.5)
return None
def fix(md_path):
"""md 파일: <img> → pic/ 저장 + ![그림](../pic/..) 참조. 폴더는 명칭 폴더의 pic/."""
text = md_path.read_text(encoding="utf-8")
@@ -43,6 +53,7 @@ def fix(md_path):
folder = md_path.parent
picdir = folder / "pic"
seq = [0]
def repl(m):
attrs = m.group(1)
ms = SRC.search(attrs)
@@ -61,16 +72,22 @@ def fix(md_path):
fn = f"{md_path.stem}_img{seq[0]}{sniff(blob)}"
(picdir / fn).write_bytes(blob)
return f"![그림](<pic/{fn}>)"
new = IMG.sub(repl, text)
# 부칙 등에서 여는 태그와 분리돼 남은 고아 닫는 태그 제거(단독 줄/인용줄 포함)
new = re.sub(r'^>?\s*</img>\s*$', ">", new, flags=re.M)
new = re.sub(r"^>?\s*</img>\s*$", ">", new, flags=re.M)
new = new.replace("</img>", "")
if new != text:
md_path.write_text(new, encoding="utf-8")
return seq[0]
if __name__ == "__main__":
mds = list(ROOT.rglob("현행_*.md")) + list(ROOT.rglob("교본시점_*.md")) + list(ROOT.rglob("CHANGELOG.md"))
mds = (
list(ROOT.rglob("현행_*.md"))
+ list(ROOT.rglob("교본시점_*.md"))
+ list(ROOT.rglob("CHANGELOG.md"))
)
mds = [m for m in mds if "임도기술교본" not in str(m) and "_pipeline" not in str(m)]
tot = 0
for md in sorted(mds):
@@ -7,6 +7,7 @@ PDF 표(정상)는 그대로 두고, 공백이 붙어버린 프로즈 줄만 HWP
- 별표: 같은 폴더 현행 XML의 별표서식파일링크(HWP) 원본을 받아 hwp5 추출
- 첨부: 같은 폴더의 .hwp/.hwpx 원본을 사용
"""
import re, sys, time, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
@@ -18,11 +19,13 @@ import hwp5_text
BASEURL = "https://www.law.go.kr"
UA = {"User-Agent": "Mozilla/5.0"}
def nsp(s):
# 매칭 키: 공백·특수문자(사설글리프·불릿·문장부호) 제거 → 한글/영숫자만.
# HWP와 PDF 추출의 글자 차이(ㅇ·ㆍ·U+F09E 등)를 흡수한다.
return re.sub(r"[^가-힣0-9A-Za-z]", "", s)
def spaced_index(hwp_path):
"""HWP 전체를 하나의 띄어쓰기 문자열로 잇고, 무공백↔원문 위치 맵을 만든다.
@@ -52,6 +55,7 @@ def respace(body, spaced, spaced_nsp, pos):
# 원문 줄바꿈(문단경계)은 공백으로
return re.sub(r"\s+", " ", seg).strip()
def download(link, dest):
url = link if link.startswith("http") else BASEURL + link
for _ in range(3):
@@ -65,6 +69,7 @@ def download(link, dest):
time.sleep(1.5)
return False
def byl_link_map(folder):
"""현행 XML → {별표 stem 접두: HWP링크}. md 파일명과 매칭용."""
xmls = sorted(folder.parent.glob("현행_*.xml"))
@@ -89,10 +94,17 @@ def byl_link_map(folder):
out[key] = link
return out
def restore_line(line, spaced, spaced_nsp, pos):
"""프로즈 줄이면 띄어쓰기 버전으로 교체. 표 행(|)은 건드리지 않는다."""
st = line.strip()
if not st or st.startswith("|") or st.startswith("#") or st.startswith(">") or st.startswith("!["):
if (
not st
or st.startswith("|")
or st.startswith("#")
or st.startswith(">")
or st.startswith("![")
):
return line
m = re.match(r"^(\s*(?:[-*]\s+|[가-힣]\.\s*|\(\d+\)\s*|\d+\.\s*)?)(.*)$", line)
prefix, body = m.group(1), m.group(2)
@@ -105,6 +117,7 @@ def restore_line(line, spaced, spaced_nsp, pos):
return prefix + sp
return line
def fix_file(md, hwp_dir_download=True):
text = md.read_text(encoding="utf-8")
folder = md.parent # .../별표 또는 .../첨부
@@ -137,5 +150,3 @@ def fix_file(md, hwp_dir_download=True):
md.write_text("\n".join(new), encoding="utf-8")
return sum(1 for a, b in zip(lines, new) if a != b)
return 0
@@ -1,31 +1,41 @@
# -*- coding: utf-8 -*-
"""고시·훈령 본문이 껍데기인 경우 실제 내용이 담긴 첨부파일/별표 원본(HWP)을 내려받는다."""
import re, time, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR))
UA = {"User-Agent": "Mozilla/5.0"}
def safe(s):
return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".")
def get(url, tries=3):
url = url.strip().replace("http://law.go.kr", "https://www.law.go.kr")
if url.startswith("/"):
@@ -40,6 +50,7 @@ def get(url, tries=3):
return None
time.sleep(2)
tot_att = tot_hwp = 0
for xml in sorted(ROOT.rglob("현행_*.xml")):
folder = xml.parent
@@ -81,6 +92,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")):
continue
try:
import pymupdf
t = "".join(pg.get_text() for pg in pymupdf.open(pdf))
except Exception:
continue
@@ -5,31 +5,40 @@ CodeList로 전체 코드를 받고, 대상 KCS 코드의 CodeViewer 본문을
표준시방서/<명칭>/KCS/<코드>_<이름>.md 저장한다.
API Key는 keyfile(_kcsc_key.txt)에서 읽는다.
"""
import json, re, sys, time, html, urllib.parse, urllib.request
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR / "표준시방서"))
SC = Path(str(DATA_DIR))
KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY
BASE = "https://kcsc.re.kr/OpenApi"
UA = {"User-Agent": "Mozilla/5.0"}
def api(path):
url = f"{BASE}/{path}{'&' if '?' in path else '?'}key={KEY}"
for k in range(3):
@@ -42,9 +51,11 @@ def api(path):
return None
time.sleep(2)
def safe(s):
return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".")
# ── HTML → Markdown ──
def cell_text(td):
t = re.sub(r"<br\s*/?>", " ", td)
@@ -53,6 +64,7 @@ def cell_text(td):
t = html.unescape(t)
return re.sub(r"\s+", " ", t).strip()
def table_to_md(tbl):
cap = ""
mcap = re.search(r"<caption[^>]*>(.*?)</caption>", tbl, re.S)
@@ -78,6 +90,7 @@ def table_to_md(tbl):
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return "\n".join(out)
def content_to_md(c):
if not c:
return ""
@@ -105,9 +118,12 @@ def content_to_md(c):
t = re.sub(r"<[^>]+>", "", t)
return html.unescape(t).strip()
def viewer_to_md(doc):
out = [f"# KCS {doc['code']} {doc['name']}", ""]
out.append(f"> 버전 {doc.get('version','')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode','')}")
out.append(
f"> 버전 {doc.get('version', '')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode', '')}"
)
out.append(f"> 출처: https://kcsc.re.kr/OpenApi/CodeViewer/{doc['codeType']}/{doc['code']}")
out.append("")
last_head = ""
@@ -144,6 +160,7 @@ def viewer_to_md(doc):
md.append(l)
return "\n".join(md).strip() + "\n"
TARGETS = {
"콘크리트 표준시방서 (KCS 14 20 00)": ["1420"],
"도로공사 표준시방서 (KCS 44 00 00)": ["44"],
@@ -151,23 +168,33 @@ TARGETS = {
"건설공사 비탈면 표준시방서 (KCS 11 70 00)": ["117", "114030"],
}
def run():
codelist = api("CodeList")
if not codelist:
print("CodeList 실패"); return
(SC / "kcsc_codelist.json").write_text(json.dumps(codelist, ensure_ascii=False), encoding="utf-8")
print("CodeList 실패")
return
(SC / "kcsc_codelist.json").write_text(
json.dumps(codelist, ensure_ascii=False), encoding="utf-8"
)
kcs = [x for x in codelist if x["codeType"] == "KCS"]
print(f"CodeList {len(codelist)}건 (KCS {len(kcs)})")
summary = []
for folder_name, prefixes in TARGETS.items():
codes = sorted({x["code"]: x for x in kcs
if any(x["code"].startswith(p) for p in prefixes)}.items())
codes = sorted(
{x["code"]: x for x in kcs if any(x["code"].startswith(p) for p in prefixes)}.items()
)
folder = ROOT / safe(folder_name) / "KCS"
folder.mkdir(parents=True, exist_ok=True)
idx = [f"# {folder_name} — KCS 코드 목록", "",
f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", "",
"| 코드 | 이름 | 버전 | md |", "|---|---|---|---|"]
idx = [
f"# {folder_name} — KCS 코드 목록",
"",
f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.",
"",
"| 코드 | 이름 | 버전 | md |",
"|---|---|---|---|",
]
ok = 0
for code, meta in codes:
doc = api(f"CodeViewer/KCS/{code}")
@@ -189,5 +216,6 @@ def run():
for n, t, o in summary:
print(f" {o}/{t} {n}")
if __name__ == "__main__":
run()
@@ -1,29 +1,38 @@
# -*- coding: utf-8 -*-
"""e나라 표준인증에서 KS 표준 메타데이터·개정이력 수집 (원문은 DRM 열람 전용이라 미수집)."""
import json, re, time, urllib.parse, urllib.request
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR / "KS"))
SC = Path(str(DATA_DIR))
BASE = "https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do"
UA = {"User-Agent": "Mozilla/5.0"}
def fetch(ks):
url = f"{BASE}?menuId=919&topMenuId=502&upperMenuId=503&ksNo={urllib.parse.quote(ks)}"
for k in range(3):
@@ -36,6 +45,7 @@ def fetch(ks):
return None
time.sleep(2)
def flatten(h):
h = re.sub(r"<script.*?</script>", "", h, flags=re.S)
h = re.sub(r"<style.*?</style>", "", h, flags=re.S)
@@ -46,10 +56,12 @@ def flatten(h):
t = re.sub(r"(\|\s*)+", "|", t)
return t
def field(t, key, stop=("|",)):
m = re.search(r"\|" + re.escape(key) + r"\|+([^|]*)", t)
return m.group(1).strip() if m else ""
def parse(ks, h):
t = flatten(h)
i = t.find("|기본정보|")
@@ -72,12 +84,25 @@ def parse(ks, h):
j = t.find("표준 이력사항")
if j > 0:
seg2 = t[j : j + 6000]
for m in re.finditer(r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2):
hist.append({"일자": m.group(1).strip(), "구분": m.group(2).strip(), "고시번호": m.group(3).strip()})
for m in re.finditer(
r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2
):
hist.append(
{
"일자": m.group(1).strip(),
"구분": m.group(2).strip(),
"고시번호": m.group(3).strip(),
}
)
d["이력"] = hist
d["상태"] = "폐지" if any(x["구분"] == "폐지" for x in hist) else ("현행" if d["표준명"] else "확인필요")
d["상태"] = (
"폐지"
if any(x["구분"] == "폐지" for x in hist)
else ("현행" if d["표준명"] else "확인필요")
)
return d
CODES = json.load(open(SC / "ks_codes.json", encoding="utf-8"))
ROOT.mkdir(parents=True, exist_ok=True)
res = []
@@ -90,7 +115,10 @@ for i, ks in enumerate(CODES, 1):
d = parse(ks, h)
d["조회번호"] = key
res.append(d)
print(f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", flush=True)
print(
f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}",
flush=True,
)
time.sleep(0.6)
json.dump(res, open(SC / "ks_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -1,20 +1,28 @@
# -*- coding: utf-8 -*-
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
"""HWP5(OLE) 본문 텍스트 추출 — 순수 파이썬.
BodyText/Section* 스트림을 (필요시 raw-deflate 해제) 레코드 파싱해
@@ -30,6 +38,7 @@ HWPTAG_CTRL_HEADER = HWPTAG_BEGIN + 55 # 0x47
HWPTAG_LIST_HEADER = HWPTAG_BEGIN + 56 # 0x48
HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d
def is_compressed(ole):
with ole.openstream("FileHeader") as f:
data = f.read()
@@ -37,6 +46,7 @@ def is_compressed(ole):
flags = struct.unpack("<I", data[36:40])[0]
return bool(flags & 1)
def records(buf):
i, n = 0, len(buf)
while i + 4 <= n:
@@ -51,11 +61,13 @@ def records(buf):
yield tag, level, buf[i : i + size]
i += size
# 인라인 확장 제어문자(뒤에 14 WCHAR = 28바이트 추가로 따라옴)
EXT_CTRL = {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 14, 15, 16, 17, 18, 21, 22, 23}
# 인라인 문자 제어(그 자체로 1 WCHAR)
INLINE = {0, 10, 13, 24, 25, 26, 27, 28, 29, 30, 31}
def para_text(data):
out = []
i, n = 0, len(data)
@@ -73,6 +85,7 @@ def para_text(data):
i += 2
return "".join(out)
def extract(path):
"""PARA_TEXT 문단만 평문 리스트로(하위호환)."""
ole = olefile.OleFileIO(path)
@@ -89,6 +102,7 @@ def extract(path):
ole.close()
return paras
def _table_md(cells, ncols):
"""셀 텍스트 리스트를 nCols 기준으로 md 표로."""
cells = [re.sub(r"\s+", " ", (c or "").replace("|", "")).strip() for c in cells]
@@ -102,6 +116,7 @@ def _table_md(cells, ncols):
out.append("| " + " | ".join(r) + " |")
return "\n".join(out)
def extract_items(path):
"""(kind, val) 아이템 리스트. kind='text'|'table'. 표를 복원한다."""
ole = olefile.OleFileIO(path)
@@ -160,6 +175,7 @@ def extract_items(path):
ole.close()
return items
if __name__ == "__main__":
paras = extract(sys.argv[1])
text = "\n".join(p for p in paras if p)
@@ -6,15 +6,18 @@ HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생
- 단일셀 (글상자) 인용블록(줄바꿈 보존)
- 번호체계(제N장 / N-N-N. / 1. / . / (1) / ) 헤딩·중첩 리스트
"""
import re, sys, zipfile
from pathlib import Path
import xml.etree.ElementTree as ET
NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
def _local(tag):
return tag.split("}")[-1]
def para_text(p):
buf = []
for el in p.iter():
@@ -27,6 +30,7 @@ def para_text(p):
buf.append("\n")
return "".join(buf)
def cell_paras(tc):
parts = []
for sub in tc.iter(f"{NS}p"):
@@ -35,9 +39,11 @@ def cell_paras(tc):
parts.append(s)
return parts
def cell_text(tc):
return " ".join(cell_paras(tc)).replace("|", "")
def table_md(tbl):
"""(kind, value) 반환. kind = 'table' | 'box' | 'text'."""
rows = [tr.findall(f"{NS}tc") for tr in tbl.findall(f"{NS}tr")]
@@ -57,6 +63,7 @@ def table_md(tbl):
out.append("| " + " | ".join(c.replace("\n", "<br>") for c in r) + " |")
return ("table", "\n".join(out))
def walk(container, out):
for child in container:
if _local(child.tag) != "p":
@@ -68,6 +75,7 @@ def walk(container, out):
else:
out.append(("text", para_text(child)))
def extract(path):
z = zipfile.ZipFile(path)
secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n))
@@ -76,6 +84,7 @@ def extract(path):
walk(ET.fromstring(z.read(sec)), out)
return out
# ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ──
CHAP = re.compile(r"^제\d+\s*장(\s|$)")
SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.)
@@ -88,6 +97,7 @@ MARKERS = [
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
]
def marker(s):
for k, rx in MARKERS:
m = rx.match(s)
@@ -95,6 +105,7 @@ def marker(s):
return k, m.group(1), m.group(2)
return None, "", s
def structure(items, header):
"""(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용."""
lines = list(header)
@@ -117,15 +128,20 @@ def structure(items, header):
continue
# 헤딩류
if CHAP.match(st):
lines += ["", f"## {st}", ""]; stack = []; continue
lines += ["", f"## {st}", ""]
stack = []
continue
if SECN.match(st):
lines += ["", f"### {st}", ""]; stack = []; continue
lines += ["", f"### {st}", ""]
stack = []
continue
if JO.match(st):
m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S)
lines += ["", f"### {m.group(1)}", ""]
if m.group(2).strip():
lines.append(m.group(2).strip())
stack = []; continue
stack = []
continue
# 리스트 마커
k, mk, rest = marker(st)
if k:
@@ -159,19 +175,23 @@ def structure(items, header):
out = out.encode("utf-8", "ignore").decode("utf-8")
return out
def to_md(path):
items = extract(path)
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""]
return structure(items, header)
def hwp5_to_md(path, header=None):
"""구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존."""
import hwp5_text
items = hwp5_text.extract_items(str(path))
if header is None:
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""]
return structure(items, header)
if __name__ == "__main__":
for f in sys.argv[1:]:
p = Path(f)
@@ -4,6 +4,7 @@
법률/행정규칙/표준시방서/<명칭>/ 하위의 본문·별표·첨부·압축해제·KCS md를 전부 링크.
최상위 `0. 참조 법령·기준 목록.md` 명칭이 _index.md 연결된다(gen_md).
"""
import re, sys
from pathlib import Path
@@ -11,9 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent
CATS = ["법률", "행정규칙", "표준시방서"]
SKIP = {"_index.md", "_목록.md"}
def rel(p, base):
return p.relative_to(base).as_posix()
def build(folder):
name = folder.name
lines = [f"# {name} — 문서 목록", ""]
@@ -24,7 +27,9 @@ def build(folder):
lines.append("## 본문")
lines.append("")
for p in top:
label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(p.stem, p.stem.replace("_", " "))
label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(
p.stem, p.stem.replace("_", " ")
)
lines.append(f"- [{label}](<{p.name}>)")
lines.append("")
@@ -70,7 +75,9 @@ def build(folder):
if grp:
lines.append(f" - **{grp}**")
for p in tree[grp]:
lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)")
lines.append(
f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)"
)
else:
for p in tree[grp]:
lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.name}>)")
@@ -86,12 +93,21 @@ def build(folder):
lines.append(f"- [{p.stem}](<KCS/{p.name}>)")
lines.append("")
total = len(top) + len(byl) + len(att) + len(kcs) + sum(
len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs)
lines.insert(1, f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total-len(top)-len(byl)-len(att)-len(kcs)} · KCS {len(kcs)})")
total = (
len(top)
+ len(byl)
+ len(att)
+ len(kcs)
+ sum(len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs)
)
lines.insert(
1,
f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total - len(top) - len(byl) - len(att) - len(kcs)} · KCS {len(kcs)})",
)
(folder / "_index.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
return total
if __name__ == "__main__":
n = 0
for cat in CATS:
@@ -4,6 +4,7 @@
이미지: 미리보기 링크 · 크기 · 형식 · 소속(명칭) · 참조 md 링크.
사용자가 표로 옮길 이미지를 직접 판단한다.
"""
import re
from pathlib import Path
from PIL import Image
@@ -11,6 +12,7 @@ from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "_이미지 목록(표 변환 검토용).md"
def _cand(p):
try:
w, h = Image.open(p).size
@@ -18,6 +20,7 @@ def _cand(p):
except Exception:
return False
def build():
# 1) 이미지 → 참조 md 역매핑
ref = {}
@@ -26,8 +29,11 @@ def build():
continue
t = m.read_text(encoding="utf-8")
# 경로에 괄호가 있어도 <...> 안이면 확장자까지 잡는다
for mm in re.finditer(r'!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)'
r'|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)', t):
for mm in re.finditer(
r"!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)"
r"|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)",
t,
):
path = mm.group(1) or mm.group(2)
img = (m.parent / path).resolve()
ref.setdefault(str(img), []).append(m)
@@ -53,31 +59,49 @@ def build():
seen, uniq = set(), []
for m in ref.get(str(p.resolve()), []):
if m not in seen:
seen.add(m); uniq.append(m)
rlinks = " · ".join(f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)"
for m in uniq[:2]) if uniq else "_미참조_"
seen.add(m)
uniq.append(m)
rlinks = (
" · ".join(
f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" for m in uniq[:2]
)
if uniq
else "_미참조_"
)
return f"| ☐ | {idx} | [{p.name[:40]}](<{rel}>) | {size} | {fmt} | {rlinks} |"
cand = [p for p in imgs if _cand(p)]
rest = [p for p in imgs if not _cand(p)]
L = ["# 이미지 목록 — 표 변환 검토용", "",
L = [
"# 이미지 목록 — 표 변환 검토용",
"",
f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.",
"> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", "",
f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", "",
"> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", "",
"> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.",
"",
f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토",
"",
"> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).",
"",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|"]
"|:-:|---:|---|---|---|---|",
]
for i, p in enumerate(sorted(cand, key=lambda x: -Image.open(x).size[0]), 1):
L.append(row(i, p))
L += ["", f"## 그 외 이미지 ({len(rest)}개)", "",
"> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", "",
L += [
"",
f"## 그 외 이미지 ({len(rest)}개)",
"",
"> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.",
"",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|"]
"|:-:|---:|---|---|---|---|",
]
for i, p in enumerate(sorted(rest), len(cand) + 1):
L.append(row(i, p))
OUT.write_text("\n".join(L) + "\n", encoding="utf-8")
return len(imgs)
if __name__ == "__main__":
n = build()
print(f"이미지 목록 {n}개 → {OUT.name}")
@@ -1,14 +1,20 @@
# -*- coding: utf-8 -*-
"""[압축] 폴더마다 _목록.md 생성 — 내부 HWP→md 파일 트리 색인."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def build(folder):
mds = sorted(p for p in folder.rglob("*.md") if p.name != "_목록.md")
lines = [f"# {folder.name} — 압축 해제 문서 목록", "",
f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", ""]
lines = [
f"# {folder.name} — 압축 해제 문서 목록",
"",
f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.",
"",
]
# 하위 폴더 구조 반영
tree = {}
for m in mds:
@@ -27,6 +33,7 @@ def build(folder):
(folder / "_목록.md").write_text("\n".join(lines), encoding="utf-8")
return len(mds)
if __name__ == "__main__":
n = 0
for folder in ROOT.rglob("[[]압축[]]*"):
@@ -5,24 +5,33 @@
- 본문은 법령 번호체계(./1././(1)/()/1)/)/) 기준으로 중첩 리스트화
- PDF 줄바꿈은 꼬리 공백을 신뢰해 그대로 이어붙임 (한글 어절 분리 방지)
"""
import re, sys, json
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v: return v.strip()
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m: return m.group(1)
if m:
return m.group(1)
return ""
import pymupdf
ROOT = Path(str(ROOT_DIR))
@@ -41,6 +50,7 @@ MARKERS = [
]
HEAD = re.compile(r"^■\s*(.+?)\s*\[(별표|별지)\s*([^\]]*)\]\s*(<[^>]*>)?\s*$")
def match_marker(s):
for kind, rx in MARKERS:
m = rx.match(s)
@@ -48,6 +58,7 @@ def match_marker(s):
return kind, m.group(1), m.group(2)
return None, "", s
# ── 페이지 → (요소 리스트) ──
def _lines(page):
out = []
@@ -60,14 +71,18 @@ def _lines(page):
out.append((pymupdf.Rect(ln["bbox"]), txt))
return out
def _nk(s):
return re.sub(r"[^가-힣0-9A-Za-z%]", "", s)
def safe(s):
return re.sub(r'[\\/:*?"<>|\s]+', "_", s).strip("_")
MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등)
def _images(page, pno, doc, picdir, stem):
"""페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환."""
out = []
@@ -94,6 +109,7 @@ def _images(page, pno, doc, picdir, stem):
out.append((r, f"![그림 {pno + 1}-{idx}](<../pic/{fn}>)"))
return out
def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
"""세로 순서대로 ('text', y, x, 문자열) / ('table', y, x, md) / ('image', y, x, ref) 반환.
@@ -112,8 +128,11 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
if not md:
continue
b = pymupdf.Rect(t.bbox)
inside = "".join(_nk(txt) for r, txt in lines
if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)))
inside = "".join(
_nk(txt)
for r, txt in lines
if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2))
)
if not inside:
continue
got = _nk(md)
@@ -138,6 +157,7 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
items.sort(key=lambda x: (round(x[1], 1), x[2]))
return items
def table_md(t):
try:
rows = t.extract()
@@ -152,8 +172,12 @@ def table_md(t):
if len(cnts) == 1 and cnts and max(cnts) > 1:
n = max(cnts)
for i in range(n):
split.append([(p[i].strip() if len(p) == n else (r[j] if i == 0 else ""))
for j, p in enumerate(parts)])
split.append(
[
(p[i].strip() if len(p) == n else (r[j] if i == 0 else ""))
for j, p in enumerate(parts)
]
)
else:
split.append(r)
rows = [[re.sub(r"\s+", " ", c).strip() for c in r] for r in split]
@@ -163,12 +187,12 @@ def table_md(t):
w = max(len(r) for r in rows)
rows = [r + [""] * (w - len(r)) for r in rows]
esc = lambda c: c.replace("|", "\\|").replace("\n", "<br>")
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
"|" + "|".join(["---"] * w) + "|"]
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
for r in rows[1:]:
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return "\n".join(out)
# ── 변환 본체 ──
def convert(pdf_path):
doc = pymupdf.open(pdf_path)
@@ -230,8 +254,13 @@ def convert(pdf_path):
stack.append(kind)
depth = len(stack) - 1
flush()
cur = {"kind": "item", "depth": depth, "marker": mk,
"head": rest.rstrip("\n"), "body": ""}
cur = {
"kind": "item",
"depth": depth,
"marker": mk,
"head": rest.rstrip("\n"),
"body": "",
}
else:
if cur is None:
cur = {"kind": "item", "depth": 0, "marker": "", "head": "", "body": ""}
@@ -291,10 +320,13 @@ def convert(pdf_path):
md.append(l)
return "\n".join(md).strip() + "\n"
if __name__ == "__main__":
targets = sys.argv[1:]
if not targets:
targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [str(p) for p in ROOT.rglob("첨부/*.pdf")]
targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [
str(p) for p in ROOT.rglob("첨부/*.pdf")
]
ok = fail = 0
for f in targets:
p = Path(f)
@@ -5,6 +5,7 @@
소스 대조: 별표·첨부 md 같은 이름 PDF, 현행 본문 md 같은 이름 XML.
결과를 qc_report.json 으로 저장하고 카테고리별 요약 출력.
"""
import json, re
from pathlib import Path
import pymupdf
@@ -12,9 +13,11 @@ import pymupdf
ROOT = Path(__file__).resolve().parent.parent
OUT = Path(__file__).resolve().parent / "data"
def norm(s):
return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s)
def strip_fenced(text):
"""``` 코드펜스 안을 빈 줄로 치환(위치 보존)."""
out, infence = [], False
@@ -26,6 +29,7 @@ def strip_fenced(text):
out.append("" if infence else l)
return chr(10).join(out)
def ncols(line):
s = line.strip()
if s.startswith("|"):
@@ -34,6 +38,7 @@ def ncols(line):
s = s[:-1]
return len(s.split("|"))
def check_tables(text):
"""마크다운 표 유효성. (문제 리스트) 반환."""
issues = []
@@ -49,7 +54,9 @@ def check_tables(text):
block.append(lines[i])
i += 1
head = ncols(block[0])
if len(block) < 2 or not set(block[1].replace("|", "").replace(" ", "").replace(":", "")) <= set("-"):
if len(block) < 2 or not set(
block[1].replace("|", "").replace(" ", "").replace(":", "")
) <= set("-"):
issues.append(f"L{start + 1} 구분선 없음/이상")
continue
for j, b in enumerate(block):
@@ -59,6 +66,7 @@ def check_tables(text):
issues.append(f"L{start + j + 1} 열수 {ncols(b)}{head}")
return issues
def check_space(text):
"""프로즈(표·펜스 제외)의 한글 12자 이상 연속 비율."""
prose = [l for l in strip_fenced(text).split(chr(10)) if not l.lstrip().startswith("|")]
@@ -69,6 +77,7 @@ def check_space(text):
runs = re.findall(r"[가-힣]{12,}", t)
return round(sum(len(x) for x in runs) / kor, 3)
def check_linebreak(text):
"""줄바꿈 결함: 표 앞 빈 줄 없음, 헤딩 직후 표 붙음."""
issues = []
@@ -77,16 +86,24 @@ def check_linebreak(text):
s = lines[i].strip()
prev = lines[i - 1].strip()
# 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님)
if s.startswith("|") and prev and not prev.startswith("|") and not prev.startswith("#") and not prev.startswith(">"):
if (
s.startswith("|")
and prev
and not prev.startswith("|")
and not prev.startswith("#")
and not prev.startswith(">")
):
issues.append(f"L{i + 1} 표 앞 빈 줄 없음")
return issues[:5]
def pdf_stats(pdf):
d = pymupdf.open(pdf)
txt = "\n".join(p.get_text() for p in d)
imgs = sum(len(p.get_images()) for p in d)
return txt, imgs
def run():
report = []
targets = []
@@ -130,7 +147,9 @@ def run():
if rec["issues"]:
report.append(rec)
json.dump(report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
json.dump(
report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1
)
# 요약
cat = {}
@@ -142,8 +161,11 @@ def run():
print("\n=== 심각(내용누락·사진누락·테이블) 상위 ===")
sev = [r for r in report if set(r["issues"]) & {"내용누락", "사진누락", "테이블"}]
for r in sev[:30]:
ks = ", ".join(f"{k}={v if not isinstance(v,list) else len(v)}" for k, v in r["issues"].items())
ks = ", ".join(
f"{k}={v if not isinstance(v, list) else len(v)}" for k, v in r["issues"].items()
)
print(f" {r['file'][-64:]} [{ks}]")
if __name__ == "__main__":
run()
@@ -13,6 +13,7 @@
- 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약( ) 목차와 일치하는지 확인
- 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(·표는 정상) W5 공백 기준 예외로 기록
"""
import re
import sys
from pathlib import Path
@@ -26,7 +27,12 @@ BASE = Path(__file__).resolve().parent.parent / "원가계산"
CAK_DIR = "노임단가_건설업_대한건설협회"
CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회"
PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표
CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)]
CAK_CH = [
("1. 조사개요", 1),
("2. 임금적용요령", 5),
("3. 개별직종 노임단가", 9),
("4. 직종해설", 13),
]
CAK_COLS = "| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |"
@@ -51,8 +57,10 @@ def parse_cak_table():
if code not in result and len(slots) == 4 and name:
result[code] = (name, slots, flag)
rows = [CAK_COLS, "|---|---|---|---|---|---|---|"]
rows += [f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |"
for c in sorted(result)]
rows += [
f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |"
for c in sorted(result)
]
return len(result), "\n".join(rows) + "\n"
@@ -72,27 +80,36 @@ def split_cak():
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n"
"> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n")
hdr = (
f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n"
"> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n"
)
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
if "개별직종" in fname:
body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
body = (
"## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n"
"> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), "
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table)
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table
)
(BASE / CAK_DIR / f"{fname}.md").write_text(
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8")
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
)
print("wrote", fname)
# ────────────────────────── 중기중앙회 노임 ──────────────────────────
KBIZ_DIR = "노임단가_제조업_중소기업중앙회"
KBIZ_STEM = "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회"
KBIZ_CH = [("1. 조사개요", 1), ("2. 조사결과 요약", 12), ("3. 직종별 조사노임", 18),
("4. 직종코드 및 직종명 해설", 30)]
KBIZ_CH = [
("1. 조사개요", 1),
("2. 조사결과 요약", 12),
("3. 직종별 조사노임", 18),
("4. 직종코드 및 직종명 해설", 30),
]
def split_kbiz():
@@ -102,13 +119,16 @@ def split_kbiz():
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n"
"> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n")
hdr = (
f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n"
"> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n"
)
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
(BASE / KBIZ_DIR / f"{fname}.md").write_text(
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8")
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
)
print("wrote", fname)
@@ -121,8 +141,13 @@ def split_pumsem():
lines = (BASE / PUM_DIR / f"{PUM_STEM}.md").read_text(encoding="utf-8").splitlines()
# 부문 표제 위치 자동 탐지 (짧은 단독 라인)
sec_names = [("01_공통부문", "공통부문"), ("02_토목부문", "토목부문"), ("03_건축부문", "건축부문"),
("04_기계설비부문", "기계설비부문"), ("05_유지관리부문", "유지관리부문")]
sec_names = [
("01_공통부문", "공통부문"),
("02_토목부문", "토목부문"),
("03_건축부문", "건축부문"),
("04_기계설비부문", "기계설비부문"),
("05_유지관리부문", "유지관리부문"),
]
hits = {}
for i, l in enumerate(lines):
s = re.sub(r"[\s#>\-·ㆍ]", "", l)
@@ -170,11 +195,14 @@ def split_pumsem():
outdir.mkdir(exist_ok=True)
for j, (n, nm, st) in enumerate(starts):
en = starts[j + 1][2] if j + 1 < len(starts) else b
hdr = (f"# {sec[3:]}{n}{nm}\n\n"
hdr = (
f"# {sec[3:]}{n}{nm}\n\n"
f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n"
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n")
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n"
)
(outdir / f"{n}장_{nm}.md").write_text(
hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8")
hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8"
)
print(sec, f"{len(starts)}/{len(names)}")

Some files were not shown because too many files have changed in this diff Show More