Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_4

This commit is contained in:
2026-10-22 19:58:54 +09:00
7 changed files with 1298 additions and 1145 deletions
@@ -45,281 +45,7 @@ def norm(s):
return re.sub(r"[\s\u00b7\u2027]", "", s)
ENTRIES = [
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률",
"약칭 산림자원법",
[r"산림자원의조성및관리에관한법률(?!시행)", r"산림자원법(?!시행)"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행령",
"",
[r"산림자원의조성및관리에관한법률시행령", r"산림자원법시행령"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행규칙",
"별표1 타당성평가·별표2 시설기준 포함",
[r"산림자원의조성및관리에관한법률시행규칙", r"산림자원법시행규칙"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률",
"약칭 산림기술법",
[r"산림기술진흥및관리에관한법률(?!시행)", r"산림기술법(?!시행)"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률 시행령",
"",
[r"산림기술진흥및관리에관한법률시행령", r"산림기술법시행령"],
),
("법률", "산림청", "산림보호법", "", [r"산림보호법"]),
("법률", "산림청", "산지관리법", "", [r"산지관리법"]),
("법률", "환경부", "자연환경보전법", "시행령·시행규칙 포함", [r"자연환경보전법"]),
("법률", "행정안전부", "자연재해대책법", "시행령·시행규칙 포함", [r"자연재해대책법"]),
("법률", "환경부", "환경영향평가법", "시행령·시행규칙 포함", [r"환경영향평가법"]),
(
"법률",
"산업통상자원부",
"국가균형발전특별법",
"시행령·시행규칙 포함",
[r"국가균형발전특별법"],
),
(
"법률",
"기획재정부",
"보조금 관리에 관한 법률",
"시행령·시행규칙 포함",
[r"보조금관리에관한법률", r"보조금에관한법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률",
"약칭 국가계약법",
[r"국가를당사자로하는계약에관한법률(?!시행)", r"국가계약법(?!시행|률)", r"국가계약법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"국가를당사자로하는계약에관한법률시행령", r"국가계약법시행령"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"약칭 지방계약법",
[r"지방자치단체를당사자로하는계약에관한법률(?!시행)", r"지방계약법(?!시행)"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"지방자치단체를당사자로하는계약에관한법률시행령", r"지방계약법시행령"],
),
("법률", "국토교통부", "도로법", "", [r"(?<![가-힣])도로법"]),
("법률", "행정안전부", "농어촌도로정비법", "", [r"농어촌도로정비법"]),
(
"법률",
"국토교통부",
"국토의 계획 및 이용에 관한 법률",
"약칭 국토계획법",
[r"국토의계획및이용에관한법률"],
),
("법률", "행정안전부", "도로명주소법", "", [r"도로명주소법(?!시행)"]),
("법률", "행정안전부", "도로명주소법 시행령", "", [r"도로명주소법시행령"]),
(
"법률",
"국토교통부",
"측량ㆍ수로조사 및 지적에 관한 법률",
"현 「공간정보의 구축 및 관리 등에 관한 법률」로 개정",
[r"측량·?수로조사및지적에관한법률"],
),
("법률", "고용노동부", "산업안전보건법", "", [r"산업안전보건법"]),
("법률", "고용노동부", "산업재해보상보험법", "", [r"산업재해보상보험법(?!의)"]),
(
"법률",
"고용노동부",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"약칭 보험료징수법",
[r"고용보험및산업재해보상보험의보험료징수등에관한법률(?!시행)"],
),
(
"법률",
"고용노동부",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"제12조·제13조",
[r"고용보험및산업재해보상보험의보험료징수등에관한법률시행령"],
),
("법률", "고용노동부", "고용보험법 시행령", "", [r"고용보험법시행령"]),
("법률", "보건복지부", "국민건강보험법", "제73조 및 시행령 제44조", [r"국민건강보험법"]),
("법률", "보건복지부", "국민연금법", "제88조제3항", [r"국민연금법"]),
("법률", "보건복지부", "노인장기요양보험법", "제9조제1항", [r"노인장기요양보험법(?!시행)"]),
(
"법률",
"보건복지부",
"노인장기요양보험법 시행령",
"제4조 장기요양보험료율",
[r"노인장기요양보험법시행령"],
),
("법률", "기획재정부", "부가가치세법", "제30조 세율", [r"부가가치세법"]),
("법률", "고용노동부", "근로기준법", "", [r"근로기준법"]),
("법률", "산업통상자원부", "산업표준화법", "KS 표준 근거", [r"산업표준화법"]),
("법률", "과학기술정보통신부", "전자서명법", "", [r"전자서명법"]),
("법률", "국토교통부", "공동주택관리법", "", [r"공동주택관리법"]),
(
"행정규칙",
"산림청",
"임도설치 및 관리 등에 관한 규정",
"약칭 임도규정 / 교본 인용: 산림청훈령 1426호(2019.10.10.)",
[r"임도설치및관리등에관한규정", r"임도설치및관리에관한규정", r"임도규정", r"임도시설규정"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 설계 및 시설기준",
"산림자원법 시행규칙 별표2",
[r"산림관리기반시설의설계및시설기준"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 범위 및 기준",
"산림자원법 시행규칙 별표1",
[r"산림관리기반시설의범위및기준"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 타당성평가 항목별 기준 및 방법",
"산림자원법 시행규칙 별표1의2",
[r"산림관리기반시설의타당성평가"],
),
(
"행정규칙",
"산림청",
"임도 품셈 적용기준 / 임도표준품셈",
"",
[r"임도품셈적용기준", r"임도표준품셈"],
),
(
"행정규칙",
"산림청",
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침",
"",
[r"자연휴양림업무처리지침"],
),
(
"행정규칙",
"산림청",
"중기운용관리예규 / 차량관리예규",
"",
[r"중기운용관리예규", r"차량관리예규"],
),
(
"행정규칙",
"법제처",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정",
"고시 재검토기한 근거",
[r"훈령·?예규등의발령및관리에관한규정"],
),
("행정규칙", "행정안전부", "국가지점번호판 규격 등 고시", "", [r"국가지점번호판규격등"]),
("행정규칙", "행정안전부", "국가지점번호 부여기준 및 방법", "", [r"국가지점번호부여기준"]),
(
"행정규칙",
"행정안전부",
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정",
"",
[r"재난구호및재난복구비용부담기준등에관한규정"],
),
(
"행정규칙",
"고용노동부",
"사업종류별 산재보험료율 고시",
"교본 인용: 고용노동부고시 2018-90호(2019년도분)",
[r"사업종류별산재보험료율"],
),
(
"행정규칙",
"고용노동부",
"건설업 산업안전보건관리비 계상 및 사용기준",
"교본 인용: 고용노동부고시 2018-72호",
[r"건설업산업안전(보건)?관리비계상및사용기준", r"산업안전보건관리비계상및사용기준"],
),
(
"행정규칙",
"국토교통부",
"(국토교통부) 사회보험의 보험료 적용기준",
"교본 인용: 국토교통부고시 2018-462호",
[r"사회보험의보험료적용기준"],
),
(
"행정규칙",
"산업통상자원부",
"엔지니어링사업대가의 기준",
"관계 중앙행정기관장 고시 / 설계·감리비 산정",
[r"엔지니어링사업대가의기준"],
),
("행정규칙", "기획재정부", "예산편성기준", "건설부문 설계비 요율", [r"예산편성기준"]),
(
"행정규칙",
"조달청",
"토목공사원가계산 제비율 적용기준",
"간접노무비·일반관리비·이윤 / 교본 인용: 2019.06.28. 발표분",
[r"토목공사원가계산제비율"],
),
("행정규칙", "기획재정부", "입찰유의서(계약예규)", "", [r"입찰에참가하고자하는자가유의"]),
("행정규칙", "국토교통부", "수치지도 작성 작업규칙", "", [r"수치지도작성작업규칙"]),
(
"행정규칙",
"환경부",
"공사장의 비산분진 발생원 시설관리기준",
"",
[r"공사장의비산분진발생원시설관리기준"],
),
(
"행정규칙",
"국토교통부",
"건설공사 감독자 업무 지침",
"임도규정 미규정 사항의 준용 근거",
[r"건설공사감독자업무지침"],
),
(
"표준시방서",
"산림청",
"임도시설공사 표준시방서",
"교본 15.4 표준시방서의 예시의 모체",
[r"임도시설공사표준시방서"],
),
("표준시방서", "국토교통부", "콘크리트 표준시방서", "", [r"콘크(과)?리트표준시방서"]),
(
"표준시방서",
"국토교통부",
"토목공사 표준시방서",
"토목공사 일반 표준시방서 포함",
[r"토목공사(일반)?표준시방서"],
),
(
"표준시방서",
"국토교통부",
"도로공사 표준시방서",
"도로공사 일반 표준시방서 포함",
[r"도로공사(일반)?표준시방서"],
),
("표준시방서", "국토교통부", "건설공사 비탈면 표준시방서", "", [r"건설공사비탈면표준시방서"]),
("표준시방서", "국토교통부", "건설공사 표준시방서", "", [r"건설공사표준시방서"]),
]
from gen_md_entries import ENTRIES
files = [p for p in sorted(BASE.rglob("*.md")) if not str(p.relative_to(BASE)).startswith("원문")]
@@ -0,0 +1,278 @@
# -*- coding: utf-8 -*-
"""참조 법령/기준 목록 등록 항목 — `gen_md.py` 에서 나눔 (값은 그대로)."""
ENTRIES = [
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률",
"약칭 산림자원법",
[r"산림자원의조성및관리에관한법률(?!시행)", r"산림자원법(?!시행)"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행령",
"",
[r"산림자원의조성및관리에관한법률시행령", r"산림자원법시행령"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행규칙",
"별표1 타당성평가·별표2 시설기준 포함",
[r"산림자원의조성및관리에관한법률시행규칙", r"산림자원법시행규칙"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률",
"약칭 산림기술법",
[r"산림기술진흥및관리에관한법률(?!시행)", r"산림기술법(?!시행)"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률 시행령",
"",
[r"산림기술진흥및관리에관한법률시행령", r"산림기술법시행령"],
),
("법률", "산림청", "산림보호법", "", [r"산림보호법"]),
("법률", "산림청", "산지관리법", "", [r"산지관리법"]),
("법률", "환경부", "자연환경보전법", "시행령·시행규칙 포함", [r"자연환경보전법"]),
("법률", "행정안전부", "자연재해대책법", "시행령·시행규칙 포함", [r"자연재해대책법"]),
("법률", "환경부", "환경영향평가법", "시행령·시행규칙 포함", [r"환경영향평가법"]),
(
"법률",
"산업통상자원부",
"국가균형발전특별법",
"시행령·시행규칙 포함",
[r"국가균형발전특별법"],
),
(
"법률",
"기획재정부",
"보조금 관리에 관한 법률",
"시행령·시행규칙 포함",
[r"보조금관리에관한법률", r"보조금에관한법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률",
"약칭 국가계약법",
[r"국가를당사자로하는계약에관한법률(?!시행)", r"국가계약법(?!시행|률)", r"국가계약법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"국가를당사자로하는계약에관한법률시행령", r"국가계약법시행령"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"약칭 지방계약법",
[r"지방자치단체를당사자로하는계약에관한법률(?!시행)", r"지방계약법(?!시행)"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"지방자치단체를당사자로하는계약에관한법률시행령", r"지방계약법시행령"],
),
("법률", "국토교통부", "도로법", "", [r"(?<![가-힣])도로법"]),
("법률", "행정안전부", "농어촌도로정비법", "", [r"농어촌도로정비법"]),
(
"법률",
"국토교통부",
"국토의 계획 및 이용에 관한 법률",
"약칭 국토계획법",
[r"국토의계획및이용에관한법률"],
),
("법률", "행정안전부", "도로명주소법", "", [r"도로명주소법(?!시행)"]),
("법률", "행정안전부", "도로명주소법 시행령", "", [r"도로명주소법시행령"]),
(
"법률",
"국토교통부",
"측량ㆍ수로조사 및 지적에 관한 법률",
"현 「공간정보의 구축 및 관리 등에 관한 법률」로 개정",
[r"측량·?수로조사및지적에관한법률"],
),
("법률", "고용노동부", "산업안전보건법", "", [r"산업안전보건법"]),
("법률", "고용노동부", "산업재해보상보험법", "", [r"산업재해보상보험법(?!의)"]),
(
"법률",
"고용노동부",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"약칭 보험료징수법",
[r"고용보험및산업재해보상보험의보험료징수등에관한법률(?!시행)"],
),
(
"법률",
"고용노동부",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"제12조·제13조",
[r"고용보험및산업재해보상보험의보험료징수등에관한법률시행령"],
),
("법률", "고용노동부", "고용보험법 시행령", "", [r"고용보험법시행령"]),
("법률", "보건복지부", "국민건강보험법", "제73조 및 시행령 제44조", [r"국민건강보험법"]),
("법률", "보건복지부", "국민연금법", "제88조제3항", [r"국민연금법"]),
("법률", "보건복지부", "노인장기요양보험법", "제9조제1항", [r"노인장기요양보험법(?!시행)"]),
(
"법률",
"보건복지부",
"노인장기요양보험법 시행령",
"제4조 장기요양보험료율",
[r"노인장기요양보험법시행령"],
),
("법률", "기획재정부", "부가가치세법", "제30조 세율", [r"부가가치세법"]),
("법률", "고용노동부", "근로기준법", "", [r"근로기준법"]),
("법률", "산업통상자원부", "산업표준화법", "KS 표준 근거", [r"산업표준화법"]),
("법률", "과학기술정보통신부", "전자서명법", "", [r"전자서명법"]),
("법률", "국토교통부", "공동주택관리법", "", [r"공동주택관리법"]),
(
"행정규칙",
"산림청",
"임도설치 및 관리 등에 관한 규정",
"약칭 임도규정 / 교본 인용: 산림청훈령 1426호(2019.10.10.)",
[r"임도설치및관리등에관한규정", r"임도설치및관리에관한규정", r"임도규정", r"임도시설규정"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 설계 및 시설기준",
"산림자원법 시행규칙 별표2",
[r"산림관리기반시설의설계및시설기준"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 범위 및 기준",
"산림자원법 시행규칙 별표1",
[r"산림관리기반시설의범위및기준"],
),
(
"행정규칙",
"산림청",
"산림관리기반시설의 타당성평가 항목별 기준 및 방법",
"산림자원법 시행규칙 별표1의2",
[r"산림관리기반시설의타당성평가"],
),
(
"행정규칙",
"산림청",
"임도 품셈 적용기준 / 임도표준품셈",
"",
[r"임도품셈적용기준", r"임도표준품셈"],
),
(
"행정규칙",
"산림청",
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침",
"",
[r"자연휴양림업무처리지침"],
),
(
"행정규칙",
"산림청",
"중기운용관리예규 / 차량관리예규",
"",
[r"중기운용관리예규", r"차량관리예규"],
),
(
"행정규칙",
"법제처",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정",
"고시 재검토기한 근거",
[r"훈령·?예규등의발령및관리에관한규정"],
),
("행정규칙", "행정안전부", "국가지점번호판 규격 등 고시", "", [r"국가지점번호판규격등"]),
("행정규칙", "행정안전부", "국가지점번호 부여기준 및 방법", "", [r"국가지점번호부여기준"]),
(
"행정규칙",
"행정안전부",
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정",
"",
[r"재난구호및재난복구비용부담기준등에관한규정"],
),
(
"행정규칙",
"고용노동부",
"사업종류별 산재보험료율 고시",
"교본 인용: 고용노동부고시 2018-90호(2019년도분)",
[r"사업종류별산재보험료율"],
),
(
"행정규칙",
"고용노동부",
"건설업 산업안전보건관리비 계상 및 사용기준",
"교본 인용: 고용노동부고시 2018-72호",
[r"건설업산업안전(보건)?관리비계상및사용기준", r"산업안전보건관리비계상및사용기준"],
),
(
"행정규칙",
"국토교통부",
"(국토교통부) 사회보험의 보험료 적용기준",
"교본 인용: 국토교통부고시 2018-462호",
[r"사회보험의보험료적용기준"],
),
(
"행정규칙",
"산업통상자원부",
"엔지니어링사업대가의 기준",
"관계 중앙행정기관장 고시 / 설계·감리비 산정",
[r"엔지니어링사업대가의기준"],
),
("행정규칙", "기획재정부", "예산편성기준", "건설부문 설계비 요율", [r"예산편성기준"]),
(
"행정규칙",
"조달청",
"토목공사원가계산 제비율 적용기준",
"간접노무비·일반관리비·이윤 / 교본 인용: 2019.06.28. 발표분",
[r"토목공사원가계산제비율"],
),
("행정규칙", "기획재정부", "입찰유의서(계약예규)", "", [r"입찰에참가하고자하는자가유의"]),
("행정규칙", "국토교통부", "수치지도 작성 작업규칙", "", [r"수치지도작성작업규칙"]),
(
"행정규칙",
"환경부",
"공사장의 비산분진 발생원 시설관리기준",
"",
[r"공사장의비산분진발생원시설관리기준"],
),
(
"행정규칙",
"국토교통부",
"건설공사 감독자 업무 지침",
"임도규정 미규정 사항의 준용 근거",
[r"건설공사감독자업무지침"],
),
(
"표준시방서",
"산림청",
"임도시설공사 표준시방서",
"교본 15.4 표준시방서의 예시의 모체",
[r"임도시설공사표준시방서"],
),
("표준시방서", "국토교통부", "콘크리트 표준시방서", "", [r"콘크(과)?리트표준시방서"]),
(
"표준시방서",
"국토교통부",
"토목공사 표준시방서",
"토목공사 일반 표준시방서 포함",
[r"토목공사(일반)?표준시방서"],
),
(
"표준시방서",
"국토교통부",
"도로공사 표준시방서",
"도로공사 일반 표준시방서 포함",
[r"도로공사(일반)?표준시방서"],
),
("표준시방서", "국토교통부", "건설공사 비탈면 표준시방서", "", [r"건설공사비탈면표준시방서"]),
("표준시방서", "국토교통부", "건설공사 표준시방서", "", [r"건설공사표준시방서"]),
]
@@ -0,0 +1,411 @@
# -*- coding: utf-8 -*-
"""품셈 md 도구 — 기계검사(check): 수 대조 · 글자 대조 (`pum_md_tool` 에서 나눔 · 밖에서는 `pum_md_tool` 로 부름)."""
from __future__ import annotations
import collections
import difflib
import hashlib
import re
import unicodedata
from pathlib import Path
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py
_NUM,
BOOKS,
COST,
HEAD_KEYS,
ROOT,
STATES,
_page_rows,
find_heading,
heading_pattern,
join_spaced,
key,
locate,
read_rows,
visual_text,
)
from pum_md_plan import _is_footer, _side_number, read_head # noqa: E402
# ── check ──────────────────────────────────────────────────────────────
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
_COMMENT = re.compile(r"<!--.*?-->", re.S)
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …)
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])")
_HOLD = "\x00"
#: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선).
#: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는
#: `<!-- 글자층 밖 -->…<!-- /글자층 밖 -->` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄.
_PUA = re.compile("[\ue000-\uf8ff]")
_OFF_OPEN, _OFF_CLOSE = "\x01", "\x02"
_OFF_MARK = re.compile(r"<!--\s*(/?)글자층 밖\s*-->")
def _same_han(ch: str) -> str:
"""호환 한자(U+F900–FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림."""
return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch
def book_of(path: Path) -> dict:
path = path.resolve()
for book in BOOKS.values():
if path.is_relative_to(book["out"].resolve()):
return book
raise ValueError(f"본문/ 아래 파일이 아님: {path}")
def split_head(text: str) -> tuple[str, str]:
"""(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지."""
lines = text.split("\n")
end = lines.index("---", 1)
return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :])
def md_lines(body: str) -> list[tuple[int, str]]:
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터.
쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌.
"""
body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body)
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
out = []
for number, line in enumerate(body.split("\n"), start=1):
if _TABLE_RULE.match(line):
continue
line = _IMAGE.sub("", line).replace("<br>", " ")
held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
line = _ESCAPED.sub(_HOLD, line)
line = re.sub(r"^\s*#+\s", "", line) # 제목
line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
for ch in held:
line = line.replace(_HOLD, ch, 1)
out.append((number, line))
return out
#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과
#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음.
_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S")
#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님.
#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨.
_NOT_REPEAT = re.compile(
r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작
r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류
r"|^\d{1,2}\.\s" # 1. 제목
r"|^[가-힣]\.\s" # 가. 제목
r"|^[①-⑳㉮-㉻]" # ① ㉮
r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예]
r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■
)
def _chars(row: str) -> collections.Counter:
"""그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대)."""
kept, _ = _split_off([(0, row)])
return collections.Counter(_tight(kept)[0])
def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter:
"""되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼.
md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음.
딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐.
"""
kept_pdf, _ = _split_off(rows)
kept_md, _ = _split_off(md_lines(body))
return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0])
def pdf_lines(
doc, head: dict[str, str], footer: re.Pattern, body: str = ""
) -> list[tuple[int, str]]:
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌.
쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌.
셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤:
① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음).
새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음.
② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄.
③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌.
"""
first, _, last = head["PDF쪽"].partition("~")
first, last = int(first), int(last or first)
start_page, start_row = locate(doc, head["시작표지"], first, slack=0)
end_page, end_row = last + 1, 0
if head["끝표지"] and head["끝표지"] != "끝":
try:
end_page, end_row = locate(doc, head["끝표지"], last, slack=1)
except ValueError:
pass
raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가)
for number in range(start_page, min(end_page, last) + 1):
rows = [row.strip() for row in read_rows(doc[number - 1])]
if rows and _is_footer(doc[number - 1], rows[-1], footer):
rows[-1] = "" # 쪽 꼬리
if rows and _side_number(doc[number - 1], rows[0]):
rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호
fresh = number > start_page # 다음 쪽 맨 위
for index, row in enumerate(rows):
if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
continue
if not row:
continue
if row == "→": # 한 쪽 안에서 표가 끊긴 자리
fresh = True
continue
raw.append((number, row, fresh))
fresh = False
budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None
gone, later = [False] * len(raw), []
since_head, at = "", 0
while at < len(raw):
number, row, fresh = raw[at]
if fresh and (run := _repeat_run(raw, at, since_head)):
chars = sum((_chars(raw[k][1]) for k in run), collections.Counter())
if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌
if budget is not None:
budget -= chars
for k in run:
gone[k] = True
at = run[-1] + 1
continue
later.append(run) # 몫이 모자란 것은 뒤로 미룸
if _ITEM_HEAD.match(row):
since_head = ""
since_head += re.sub(r"\s+", "", row)
at += 1
for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리
for k in run:
chars = _chars(raw[k][1])
if chars - budget:
break
budget -= chars
gone[k] = True
return [(number, row) for (number, row, _), out in zip(raw, gone) if not out]
def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]:
"""이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지."""
run, trial = [], ""
for k in range(at, len(raw)):
_, row, fresh = raw[k]
if (k > at and fresh) or _NOT_REPEAT.match(row):
break
grown = trial + re.sub(r"\s+", "", row)
if not grown or grown not in since_head:
break
trial = grown
run.append(k)
return run
def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
"""(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄."""
kept, off, inside = [], [], False
for at, line in lines:
keep, gone = [], []
for ch in line:
if ch in (_OFF_OPEN, _OFF_CLOSE):
inside = ch == _OFF_OPEN
elif inside or _PUA.match(ch):
# 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」)
keep.append(" ")
gone.append(ch)
else:
keep.append(ch)
kept.append((at, "".join(keep)))
if "".join(gone).strip():
off.append((at, "".join(gone)))
return kept, off
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로."""
text, where = [], []
for at, line in lines:
for ch in re.sub(r"\s", "", line):
text.append(_same_han(ch))
where.append(at)
return "".join(text), where
def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]:
counts, where = collections.Counter(), collections.defaultdict(list)
for at, line in lines:
for token in _NUM.findall(line):
counts[token] += 1
where[token].append(at)
return counts, where
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈."""
pdf, pdf_off = _split_off(pdf)
md, md_off = _split_off(md)
pdf_n, pdf_at = _numbers(pdf)
md_n, md_at = _numbers(md)
pdf_t, pdf_where = _tight(pdf)
md_t, md_where = _tight(md)
missing_c = collections.Counter(pdf_t) - collections.Counter(md_t)
extra_c = collections.Counter(md_t) - collections.Counter(pdf_t)
spots = []
if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만)
matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False)
for op, i1, i2, j1, j2 in matcher.get_opcodes():
gone, added = pdf_t[i1:i2], md_t[j1:j2]
if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)):
continue
spots.append(
{
"pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None,
"md": md_where[min(j1, len(md_where) - 1)] if md_where else None,
"context": pdf_t[max(0, i1 - 8) : i1],
"pdf_only": gone,
"md_only": added,
}
)
return {
"num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()},
"num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()},
"char_missing": missing_c,
"char_extra": extra_c,
"spots": spots,
"off_pdf": (
sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off),
sorted({a for a, _ in pdf_off}),
),
"off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off),
}
#: 자간 벌린 수 — md 주석 `<!-- 자간 벌린 수: 「1 . 3」→「1.3」 · 「3 0 4」→「304」 -->` (규칙 3-13).
#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
_SPACED = re.compile(r"<!--\s*자간 벌린 수:(.*?)-->", re.S)
_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것
#: md 주석 `<!-- 글자층 붙은 수: 「0.3350.335」→「0.335 0.335」 -->` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함.
_GLUED = re.compile(r"<!--\s*글자층 붙은 수:(.*?)-->", re.S)
#: 기계검사 예외 — md 주석 `<!-- 기계검사 예외: 되풀이 머리 -->` (규칙 6장 · 머리 바로 아래 한 줄).
#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임.
#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음.
_EXEMPT = re.compile(r"<!--\s*기계검사 예외:\s*되풀이 머리\s*-->")
def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok):
"""(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만)."""
done, bad = 0, []
for block in mark.findall(body):
for old, new in _SPACED_PAIR.findall(block):
hits = sum(line.count(old) for _, line in pdf)
if not hits or not ok(old, new):
bad.append(old)
continue
pdf = [(at, line.replace(old, new)) for at, line in pdf]
done += hits
return pdf, done, bad
def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
"""(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight)
def split_glued_numbers(pdf: list[tuple[int, str]], body: str):
"""(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름."""
return _rewrite(
pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued
)
def verdict(result: dict) -> str:
counts = (
sum(v for v, _ in result["num_missing"].values()),
sum(v for v, _ in result["num_extra"].values()),
sum(result["char_missing"].values()),
sum(result["char_extra"].values()),
)
joined, bad = result.get("spaced", (0, []))
split, bad_glued = result.get("glued", (0, []))
clean = not any(counts) and not bad and not bad_glued
word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") else "불통")
line = (
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
)
glyphs, pages = result.get("off_pdf", (0, []))
if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게
where = f"({', '.join(map(str, pages))}쪽)" if pages else ""
line += f" · 글자층 밖 글리프 {glyphs}개{where} · 받아씀 {result.get('off_md', 0)}자"
if joined: # ③ 이 그림으로 그 수를 꼭 보게
line += f" · 자간 벌린 수 {joined}곳"
if bad:
line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad)
if split: # ③ 이 그림으로 칸마다 값을 꼭 보게
line += f" · 글자층 붙은 수 {split}곳"
if bad_glued:
line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued)
return line
def passed(line: str) -> str:
"""기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」."""
return line.startswith(("통과", "예외"))
def fingerprint(text: str) -> str:
"""확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌."""
lines = text.replace("\r\n", "\n").split("\n")
kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))]
return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest()
def set_head(text: str, field: str, value: str) -> str:
head, body = split_head(text)
head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1)
return head + "\n" + body
def check_file(path: Path) -> tuple[dict, str]:
"""(대조 결과, 한 줄 판정) — 파일은 안 고침."""
import pymupdf
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
book = book_of(path)
with pymupdf.open(book["pdf"]) as doc:
pdf = pdf_lines(doc, read_head(path), book["footer"], body)
pdf, joined, bad = join_spaced_numbers(pdf, body)
pdf, split, bad_glued = split_glued_numbers(pdf, body)
result = compare(pdf, md_lines(body))
result["spaced"] = (joined, bad)
result["glued"] = (split, bad_glued)
result["exempt"] = bool(_EXEMPT.search(body))
return result, verdict(result)
def check(path: Path) -> int:
"""검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음.
"""
result, line = check_file(path)
print(f"{path.name}: {line}")
for token, (count, at) in sorted(result["num_missing"].items()):
print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
for token, (count, at) in sorted(result["num_extra"].items()):
print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
for spot in result["spots"][:40]:
print(
f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
)
text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
head = read_head(path)
if head.get("상태") == "확정" and passed(line) and not head.get("확정지문"):
text = set_head(text, "확정지문", fingerprint(text))
path.write_text(text, encoding="utf-8", newline="\n")
return 0 if passed(line) else 1
@@ -0,0 +1,307 @@
# -*- coding: utf-8 -*-
"""품셈 md 도구 — 차례 읽기 · 새 md 틀 만들기(plan) (`pum_md_tool` 에서 나눔 · 밖에서는 `pum_md_tool` 로 부름)."""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py
_NUM,
BOOKS,
COST,
HEAD_KEYS,
ROOT,
STATES,
_page_rows,
find_heading,
heading_pattern,
join_spaced,
key,
locate,
read_rows,
visual_text,
)
_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"}
_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*")
_BAD_NAME = re.compile(r'[/:*?"<>|\\]')
_LEADER = re.compile(r"\s*[·․…]{3,}.*$")
#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽)
_EMPTY_PAGE_CHARS = 20
@dataclass
class Entry:
"""차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록."""
ident: str
name: str
page: int # 인쇄 쪽
def toc_entries(doc, book: dict) -> list[Entry]:
"""차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0)."""
first, last = book["toc"]
raw = [
line for number in range(first - 1, last) for line in doc[number].get_text().splitlines()
]
if book["toc_layout"] == "inline":
return parse_toc_inline(raw)
return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP])
def parse_toc(raw: list[str]) -> list[Entry]:
lines = [
t for t in (_LEADER.sub("", line).strip() for line in raw) if t
] # 점선 뗌(「부록 ····」)
out, i = [], 0
while i < len(lines):
if not _TOC_ID.fullmatch(lines[i]):
raise ValueError(f"차례 모양이 다름: {lines[i]!r}")
j, name = i + 1, []
while j < len(lines) and not lines[j].isdigit():
name.append(lines[j])
j += 1
if j == len(lines):
raise ValueError(f"차례 쪽 번호 없음: {lines[i]}")
out.append(Entry(lines[i], " ".join(name), int(lines[j])))
i = j + 1
return out
_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$")
_DIVISION = re.compile(r"^\S+부문$")
def parse_toc_inline(raw: list[str]) -> list[Entry]:
"""건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄."""
lines = [line.strip() for line in raw if line.strip()]
out, i = [], 0
while i < len(lines):
line = lines[i]
if found := _INLINE.match(line):
out.append(Entry(found[1], found[2], int(found[3])))
elif re.fullmatch(r"제\d+장", line):
out.append(Entry(line, lines[i + 1], int(lines[i + 2])))
i += 2
elif _DIVISION.match(line):
out.append(Entry(line, "", 0))
elif not (
line.replace(" ", "") == "목차" or line.isdigit()
): # 쪽 머리 「목차」 · 차례 쪽 번호
raise ValueError(f"차례 모양이 다름: {line!r}")
i += 1
return out
def name_of(ident: str, title: str, dot: bool = True) -> str:
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
return title[heading_pattern(ident, dot).match(title).end() :].strip()
#: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김)
_REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$")
def file_label(ident: str, title: str, dot: bool = True) -> str:
return safe(_REVISION.sub("", name_of(ident, title, dot)))
def claimed_elsewhere(root: Path) -> set[str]:
"""다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘."""
import subprocess
rel = root.relative_to(ROOT).as_posix()
refs = subprocess.run(
["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"],
capture_output=True,
text=True,
check=True,
).stdout.split()
out: set[str] = set()
for ref in refs:
listed = subprocess.run(
[
"git",
"-C",
str(ROOT),
"-c",
"core.quotepath=off",
"ls-tree",
"-r",
"--name-only",
ref,
"--",
rel,
],
capture_output=True,
text=True,
encoding="utf-8",
)
out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line)
return out # root 아래 상대 경로
def safe(text: str) -> str:
# 글자층 밖 글리프(PUA)는 이름에서 뺌
text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF)
return _BAD_NAME.sub("", " ".join(text.split())).replace(" ", "_")
def _span(a: int, b: int) -> str:
return str(a) if a == b else f"{a}~{b}"
def head_text(values: dict[str, str]) -> str:
return (
"---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n"
)
def read_head(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0] != "---":
raise ValueError(f"머리 없음: {path}")
out = {}
for line in lines[1:]:
if line == "---":
return out
k, _, v = line.partition(":")
out[k.strip()] = v.strip()
raise ValueError(f"머리 끝 없음: {path}")
#: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리
_SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_")
#: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장)
_MAX_PAGES = 40
def _side_number(page, row: str) -> bool:
"""가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음."""
return page.rect.width > page.rect.height and row.isdigit()
def _is_footer(page, row: str, footer: re.Pattern) -> bool:
return bool(footer.match(row)) or _side_number(page, row)
def _body_rows(page, footer: re.Pattern) -> list[str]:
"""쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌."""
rows = [row.strip() for row in _page_rows(page)]
if rows and _side_number(page, rows[0]):
rows = rows[1:]
if rows and _is_footer(page, rows[-1], footer):
rows = rows[:-1]
return [row for row in rows if row != "→"]
def _blank(page, footer: re.Pattern) -> bool:
return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS
def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""):
"""(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌."""
offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"]
start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot)
if following is None: # 문서 맨 끝
end, marker = doc.page_count, "끝"
else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목
nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot)
end = nxt - 1 if at == 0 else nxt
for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음
if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]):
end = page - 1
break
while end > start and _blank(doc[end - 1], footer):
end -= 1
return start, title, max(end, start), marker
def plan(book_name: str) -> list[Path]:
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다.
**이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄).
건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 장 번호가 1부터.
"""
import pymupdf
book = BOOKS[book_name]
out, dot, footer = book["out"], book["dot"], book["footer"]
offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽
have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")}
taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m}
folders = {
(m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel))
}
written = []
def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None:
if f"{division}/{chapter_no}/{slot}" in taken:
return
# 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준
drawn = any(
_blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1)
)
path = out / division / folders[(division, chapter_no)] / file_name
path.parent.mkdir(parents=True, exist_ok=True)
values = {"문서": book["doc"], "절": title, "시작표지": title, "끝표지": marker}
values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)}
values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"}
path.write_text(head_text(values), encoding="utf-8")
written.append(path)
with pymupdf.open(book["pdf"]) as doc:
entries = toc_entries(doc, book)
# 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌)
at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2]
bounds = [entries[i] for i in at]
division, divisions, chapter_no = "", 0, None
for n, entry in enumerate(bounds):
if _DIVISION.match(entry.ident):
divisions += 1
division, chapter_no = f"{divisions:02d}_{entry.ident}", None
continue
if entry.ident.startswith("부록"):
continue
j = next(
(k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None
)
following = bounds[j] if j is not None else None
cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문
start, title, end, marker = _span_of(doc, book, entry, following, cover)
if entry.ident.startswith("제"):
chapter_no = int(entry.ident[1:-1])
folders.setdefault(
(division, chapter_no),
f"제{chapter_no:02d}장_{file_label(entry.ident, title, dot)}",
)
write(
division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc
)
continue
if chapter_no is None:
continue
major, minor = entry.ident.split("-")
slot = f"{major}-{int(minor):02d}"
region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)]
items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident]
if end - start + 1 <= _MAX_PAGES or not items:
name = f"{slot}_{file_label(entry.ident, title, dot)}.md"
write(division, chapter_no, slot, name, start, title, end, marker, doc)
continue
# 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다
head_end = _span_of(doc, book, entry, items[0])
write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc)
for k, item in enumerate(items):
piece = _span_of(
doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover
)
if k + 1 == len(items): # 마지막 항은 절 끝까지
piece = (piece[0], piece[1], end, marker)
item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}"
name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md"
write(division, chapter_no, item_slot, name, *piece, doc)
return written
@@ -15,12 +15,7 @@
from __future__ import annotations
import collections
import difflib
import hashlib
import re
import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py
@@ -40,670 +35,73 @@ from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·
visual_text,
)
_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"}
_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*")
_BAD_NAME = re.compile(r'[/:*?"<>|\\]')
_LEADER = re.compile(r"\s*[·․…]{3,}.*$")
#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽)
_EMPTY_PAGE_CHARS = 20
@dataclass
class Entry:
"""차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록."""
ident: str
name: str
page: int # 인쇄 쪽
def toc_entries(doc, book: dict) -> list[Entry]:
"""차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0)."""
first, last = book["toc"]
raw = [
line for number in range(first - 1, last) for line in doc[number].get_text().splitlines()
]
if book["toc_layout"] == "inline":
return parse_toc_inline(raw)
return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP])
def parse_toc(raw: list[str]) -> list[Entry]:
lines = [
t for t in (_LEADER.sub("", line).strip() for line in raw) if t
] # 점선 뗌(「부록 ····」)
out, i = [], 0
while i < len(lines):
if not _TOC_ID.fullmatch(lines[i]):
raise ValueError(f"차례 모양이 다름: {lines[i]!r}")
j, name = i + 1, []
while j < len(lines) and not lines[j].isdigit():
name.append(lines[j])
j += 1
if j == len(lines):
raise ValueError(f"차례 쪽 번호 없음: {lines[i]}")
out.append(Entry(lines[i], " ".join(name), int(lines[j])))
i = j + 1
return out
_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$")
_DIVISION = re.compile(r"^\S+부문$")
def parse_toc_inline(raw: list[str]) -> list[Entry]:
"""건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄."""
lines = [line.strip() for line in raw if line.strip()]
out, i = [], 0
while i < len(lines):
line = lines[i]
if found := _INLINE.match(line):
out.append(Entry(found[1], found[2], int(found[3])))
elif re.fullmatch(r"제\d+장", line):
out.append(Entry(line, lines[i + 1], int(lines[i + 2])))
i += 2
elif _DIVISION.match(line):
out.append(Entry(line, "", 0))
elif not (
line.replace(" ", "") == "목차" or line.isdigit()
): # 쪽 머리 「목차」 · 차례 쪽 번호
raise ValueError(f"차례 모양이 다름: {line!r}")
i += 1
return out
def name_of(ident: str, title: str, dot: bool = True) -> str:
"""머리 글자에서 번호를 뗀 이름(「9-5. 발파암」 → 「발파암」)."""
return title[heading_pattern(ident, dot).match(title).end() :].strip()
#: 파일 이름에서 뗄 개정 표지 — 「수치지도 작성('21, '22, '24, '26년 보완)」(머리 글자에는 남김)
_REVISION = re.compile(r"\s*\([^()]*(?:보완|신설|개정|삭제)[^()]*\)\s*$")
def file_label(ident: str, title: str, dot: bool = True) -> str:
return safe(_REVISION.sub("", name_of(ident, title, dot)))
def claimed_elsewhere(root: Path) -> set[str]:
"""다른 창 브랜치에 이미 있는 새 md 경로 — 만들면 합칠 때 같은 자리 두 벌로 부딪힘."""
import subprocess
rel = root.relative_to(ROOT).as_posix()
refs = subprocess.run(
["git", "-C", str(ROOT), "for-each-ref", "--format=%(refname)", "refs/remotes/origin"],
capture_output=True,
text=True,
check=True,
).stdout.split()
out: set[str] = set()
for ref in refs:
listed = subprocess.run(
[
"git",
"-C",
str(ROOT),
"-c",
"core.quotepath=off",
"ls-tree",
"-r",
"--name-only",
ref,
"--",
rel,
],
capture_output=True,
text=True,
encoding="utf-8",
)
out.update(line[len(rel) + 1 :] for line in listed.stdout.splitlines() if line)
return out # root 아래 상대 경로
def safe(text: str) -> str:
# 글자층 밖 글리프(PUA)는 이름에서 뺌
text = "".join(c for c in text if not 0xE000 <= ord(c) <= 0xF8FF)
return _BAD_NAME.sub("", " ".join(text.split())).replace(" ", "_")
def _span(a: int, b: int) -> str:
return str(a) if a == b else f"{a}~{b}"
def head_text(values: dict[str, str]) -> str:
return (
"---\n" + "".join(f"{k}: {values.get(k, '')}".rstrip() + "\n" for k in HEAD_KEYS) + "---\n"
)
def read_head(path: Path) -> dict[str, str]:
lines = path.read_text(encoding="utf-8").splitlines()
if not lines or lines[0] != "---":
raise ValueError(f"머리 없음: {path}")
out = {}
for line in lines[1:]:
if line == "---":
return out
k, _, v = line.partition(":")
out[k.strip()] = v.strip()
raise ValueError(f"머리 끝 없음: {path}")
#: 자리 — (부문 폴더) / 장 번호 / 절(·항) 번호. 이름이 달라도 번호가 같으면 같은 자리
_SLOT = re.compile(r"(?:([^/]+)/)?제(\d+)장_[^/]*/(\d+-\d+(?:-\d+)?)_")
#: 한 파일 쪽 상한 — 넘는 절은 항(N-N-N) 단위로 쪼갬(규칙 1장)
_MAX_PAGES = 40
def _side_number(page, row: str) -> bool:
"""가로로 눕힌 쪽(건설 813~817)의 옆 띠 쪽 번호 — 맨 끝(홀수 쪽) 또는 맨 앞(짝수 쪽) 줄에 수만 남음."""
return page.rect.width > page.rect.height and row.isdigit()
def _is_footer(page, row: str, footer: re.Pattern) -> bool:
return bool(footer.match(row)) or _side_number(page, row)
def _body_rows(page, footer: re.Pattern) -> list[str]:
"""쪽의 줄 — 맨 아래 쪽 꼬리(쪽 번호 · 장 이름)와 이어짐 표지 「→」 는 뺌."""
rows = [row.strip() for row in _page_rows(page)]
if rows and _side_number(page, rows[0]):
rows = rows[1:]
if rows and _is_footer(page, rows[-1], footer):
rows = rows[:-1]
return [row for row in rows if row != "→"]
def _blank(page, footer: re.Pattern) -> bool:
return len(re.sub(r"\s", "", "".join(_body_rows(page, footer)))) < _EMPTY_PAGE_CHARS
def _span_of(doc, book: dict, entry: Entry, following: Entry | None, cover: str = ""):
"""(시작 PDF 쪽, 머리 글자, 끝 PDF 쪽, 끝표지) — 끝은 다음 머리 앞 · 부문 표지 앞 · 뒤 빈 쪽 뺌."""
offset, dot, footer = book["first_body"] - 1, book["dot"], book["footer"]
start, _, title = find_heading(doc, entry.ident, entry.page + offset, dot=dot)
if following is None: # 문서 맨 끝
end, marker = doc.page_count, "끝"
else: # 장 마지막 절이면 다음 장 제목 · 부록 앞이면 부록 제목
nxt, at, marker = find_heading(doc, following.ident, following.page + offset, dot=dot)
end = nxt - 1 if at == 0 else nxt
for page in range(nxt - 1, start, -1) if cover else (): # 다음 부문 표지 쪽 앞에서 끊음
if any(r.strip() == cover for r in _page_rows(doc[page - 1])[:3]):
end = page - 1
break
while end > start and _blank(doc[end - 1], footer):
end -= 1
return start, title, max(end, start), marker
def plan(book_name: str) -> list[Path]:
"""장마다 `0-00_장머리.md` + 절마다 빈 파일(머리만 · 상태 대기) · 40쪽 넘는 절은 `N-NN-00_절머리.md` + 항마다.
**이미 있는 자리는 안 건드림** — 이 폴더에 있거나 다른 창 브랜치에 있는 장·절(이름이 달라도 번호로 봄).
건설은 부문 폴더(`01_공통부문`) 아래 · 부문마다 장 번호가 1부터.
"""
import pymupdf
book = BOOKS[book_name]
out, dot, footer = book["out"], book["dot"], book["footer"]
offset = book["first_body"] - 1 # 인쇄 쪽 + offset = PDF 쪽
have = claimed_elsewhere(out) | {p.relative_to(out).as_posix() for p in out.rglob("*.md")}
taken = {f"{m[1] or ''}/{int(m[2])}/{m[3]}" for m in map(_SLOT.match, have) if m}
folders = {
(m[1] or "", int(m[2])): rel.split("/")[-2] for rel in have if (m := _SLOT.match(rel))
}
written = []
def write(division, chapter_no, slot, file_name, start, title, end, marker, doc) -> None:
if f"{division}/{chapter_no}/{slot}" in taken:
return
# 그림 쪽 — 글자층 없이 그림만 있는 쪽이 끼면 받아쓰기는 그림 기준
drawn = any(
_blank(doc[p - 1], footer) and doc[p - 1].get_images() for p in range(start, end + 1)
)
path = out / division / folders[(division, chapter_no)] / file_name
path.parent.mkdir(parents=True, exist_ok=True)
values = {"문서": book["doc"], "절": title, "시작표지": title, "끝표지": marker}
values |= {"PDF쪽": _span(start, end), "인쇄쪽": _span(start - offset, end - offset)}
values |= {"상태": "대기", "원천": "그림" if drawn else "글자층"}
path.write_text(head_text(values), encoding="utf-8")
written.append(path)
with pymupdf.open(book["pdf"]) as doc:
entries = toc_entries(doc, book)
# 경계가 되는 차례 줄 — 부문 · 장 · 절 · 부록N(항은 절 안 · 머리 없는 「부록」 묶음 줄은 뺌)
at = [i for i, e in enumerate(entries) if e.ident != "부록" and e.ident.count("-") < 2]
bounds = [entries[i] for i in at]
division, divisions, chapter_no = "", 0, None
for n, entry in enumerate(bounds):
if _DIVISION.match(entry.ident):
divisions += 1
division, chapter_no = f"{divisions:02d}_{entry.ident}", None
continue
if entry.ident.startswith("부록"):
continue
j = next(
(k for k in range(n + 1, len(bounds)) if not _DIVISION.match(bounds[k].ident)), None
)
following = bounds[j] if j is not None else None
cover = bounds[j - 1].ident if j is not None and j - 1 > n else "" # 사이에 낀 부문
start, title, end, marker = _span_of(doc, book, entry, following, cover)
if entry.ident.startswith("제"):
chapter_no = int(entry.ident[1:-1])
folders.setdefault(
(division, chapter_no),
f"제{chapter_no:02d}장_{file_label(entry.ident, title, dot)}",
)
write(
division, chapter_no, "0-00", "0-00_장머리.md", start, title, end, marker, doc
)
continue
if chapter_no is None:
continue
major, minor = entry.ident.split("-")
slot = f"{major}-{int(minor):02d}"
region = entries[at[n] + 1 : at[n + 1] if n + 1 < len(at) else len(entries)]
items = [e for e in region if e.ident.rsplit("-", 1)[0] == entry.ident]
if end - start + 1 <= _MAX_PAGES or not items:
name = f"{slot}_{file_label(entry.ident, title, dot)}.md"
write(division, chapter_no, slot, name, start, title, end, marker, doc)
continue
# 40쪽 넘는 절 — 절머리(절 제목 ~ 첫 항 앞) + 항마다
head_end = _span_of(doc, book, entry, items[0])
write(division, chapter_no, f"{slot}-00", f"{slot}-00_절머리.md", *head_end, doc)
for k, item in enumerate(items):
piece = _span_of(
doc, book, item, items[k + 1] if k + 1 < len(items) else following, cover
)
if k + 1 == len(items): # 마지막 항은 절 끝까지
piece = (piece[0], piece[1], end, marker)
item_slot = f"{slot}-{int(item.ident.rsplit('-', 1)[1]):02d}"
name = f"{item_slot}_{file_label(item.ident, piece[1], dot)}.md"
write(division, chapter_no, item_slot, name, *piece, doc)
return written
# ── check ──────────────────────────────────────────────────────────────
#: md 문법 — 글자 대조·수 대조에서 뺌(원문 글자가 아님)
_COMMENT = re.compile(r"<!--.*?-->", re.S)
_IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
_TABLE_RULE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$")
#: 막음표 — 원문 글자가 md 문법으로 읽히지 않게 앞에 `\` 를 붙인 것(`\*` `\-` `\#` `\>` `\|` …)
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!|>~^])")
_HOLD = "\x00"
#: 글자층 밖 — PDF 글자층의 사용자 영역 글리프(U+E000–F8FF)는 뜻 없는 코드(식의 「(m³)」·분수선).
#: ①② 에서 빼고 개수·쪽을 기계검사 칸에 드러냄(③ 이 그림으로 봄). md 에서 그 자리를 받아쓴 글자는
#: `<!-- 글자층 밖 -->…<!-- /글자층 밖 -->` 로 감싸면 ①② 에서 빼고 「받아씀 N자」로 드러냄.
_PUA = re.compile("[\ue000-\uf8ff]")
_OFF_OPEN, _OFF_CLOSE = "\x01", "\x02"
_OFF_MARK = re.compile(r"<!--\s*(/?)글자층 밖\s*-->")
def _same_han(ch: str) -> str:
"""호환 한자(U+F900–FAFF) → 통합 한자(率 U+F9DB → 率 U+7387). ⚠ NFKC 통째로는 안 씀 — ㎥·①·㎡ 가 풀림."""
return unicodedata.normalize("NFKC", ch) if "\uf900" <= ch <= "\ufaff" else ch
def book_of(path: Path) -> dict:
path = path.resolve()
for book in BOOKS.values():
if path.is_relative_to(book["out"].resolve()):
return book
raise ValueError(f"본문/ 아래 파일이 아님: {path}")
def split_head(text: str) -> tuple[str, str]:
"""(머리, 몸) — 머리는 첫 `---` 부터 다음 `---` 까지."""
lines = text.split("\n")
end = lines.index("---", 1)
return "\n".join(lines[: end + 1]), "\n".join(lines[end + 1 :])
def md_lines(body: str) -> list[tuple[int, str]]:
"""몸의 (줄 번호, 원문 글자) — 주석 · 그림 · 표 구분 줄 · md 문법 글자를 뺌. 줄 번호는 몸 기준 1부터.
쪽을 넘은 표가 되풀이 찍은 머리 줄은 md 표에 아예 안 적음(규칙 4-5) — `pdf_lines` 가 스스로 뺌.
"""
body = _OFF_MARK.sub(lambda m: _OFF_CLOSE if m.group(1) else _OFF_OPEN, body)
body = _COMMENT.sub(lambda m: "\n" * m.group(0).count("\n"), body)
out = []
for number, line in enumerate(body.split("\n"), start=1):
if _TABLE_RULE.match(line):
continue
line = _IMAGE.sub("", line).replace("<br>", " ")
held = _ESCAPED.findall(line) # 막음표 뒤 원문 글자는 아래 문법 떼기에서 지킴
line = _ESCAPED.sub(_HOLD, line)
line = re.sub(r"^\s*#+\s", "", line) # 제목
line = re.sub(r"^\s*(>\s*)+", "", line) # 인용
line = re.sub(r"[|^_]", " ", line) # 표 칸 · 첨자 표시(`10^-7` · `C_m`)
for ch in held:
line = line.replace(_HOLD, ch, 1)
out.append((number, line))
return out
#: 항 경계 — 「9-17-4 제목」·「(7505) 제목」 같은 줄이 나오면 그 앞까지 본 줄은 잊음(딴 항의 흔한 머리말과
#: 안 헷갈리게). 분류번호(4자리-4자리)는 안 걸리게 앞 수를 1~2자리로 잡음.
_ITEM_HEAD = re.compile(r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s\S|^\(\d+\)\s\S")
#: 되풀이 머리로 안 보는 줄 — 목·항 머리, 번호 매긴 줄, 「[주]」·「[참고]」·「<…>」. 표 머리는 이런 모양이 아님.
#: 여기서 되풀이 빼기를 멈춤 — 같은 글이 앞에 또 있어도 새 표·새 목의 첫 줄이므로 빼면 안 됨.
_NOT_REPEAT = re.compile(
r"^\d{1,2}-\d{1,2}(?:-\d{1,2})?\s" # 13-6-3 수문 제작
r"|^\(\d+\)\s" # (1) 제목 · (7505) 분류
r"|^\d{1,2}\.\s" # 1. 제목
r"|^[가-힣]\.\s" # 가. 제목
r"|^[①-⑳㉮-㉻]" # ① ㉮
r"|^\[[가-힣]{1,6}\]" # [주] [참고] [설계예]
r"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■
from pum_md_plan import ( # noqa: E402,F401 — 차례 읽기 · 새 md 틀은 pum_md_plan.py
Entry,
_BAD_NAME,
_DIVISION,
_EMPTY_PAGE_CHARS,
_INLINE,
_LEADER,
_MAX_PAGES,
_REVISION,
_SLOT,
_TOC_ID,
_TOC_SKIP,
_blank,
_body_rows,
_is_footer,
_side_number,
_span,
_span_of,
claimed_elsewhere,
file_label,
head_text,
name_of,
parse_toc,
parse_toc_inline,
plan,
read_head,
safe,
toc_entries,
)
from pum_md_check import ( # noqa: E402,F401 — 기계검사는 pum_md_check.py
_COMMENT,
_ESCAPED,
_EXEMPT,
_GLUED,
_HOLD,
_IMAGE,
_ITEM_HEAD,
_NOT_REPEAT,
_OFF_CLOSE,
_OFF_MARK,
_OFF_OPEN,
_PUA,
_SPACED,
_SPACED_PAIR,
_TABLE_RULE,
_chars,
_numbers,
_repeat_budget,
_repeat_run,
_rewrite,
_same_han,
_split_off,
_tight,
book_of,
check,
check_file,
compare,
fingerprint,
join_spaced_numbers,
md_lines,
passed,
pdf_lines,
set_head,
split_glued_numbers,
split_head,
verdict,
)
def _chars(row: str) -> collections.Counter:
"""그 줄의 글자 셈 — 글자층 밖 글리프·공백을 뺀 뒤 호환 한자를 통합 한자로(②글자 대조와 같은 잣대)."""
kept, _ = _split_off([(0, row)])
return collections.Counter(_tight(kept)[0])
def _repeat_budget(rows: list[tuple[int, str]], body: str) -> collections.Counter:
"""되풀이 머리로 뺄 수 있는 글자 셈 — PDF 가 md 보다 더 가진 만큼.
md 는 되풀이된 머리를 안 적으므로(규칙 4-5) 그 차이가 곧 뺄 몫 — 이 몫을 넘겨 빼지 않음.
딴 표의 흔한 머리말이 우연히 앞 글과 같아도 몫이 없으면 안 빠짐.
"""
kept_pdf, _ = _split_off(rows)
kept_md, _ = _split_off(md_lines(body))
return collections.Counter(_tight(kept_pdf)[0]) - collections.Counter(_tight(kept_md)[0])
def pdf_lines(
doc, head: dict[str, str], footer: re.Pattern, body: str = ""
) -> list[tuple[int, str]]:
"""그 절 PDF 글자층의 (PDF 쪽, 줄) — 시작표지 줄부터 끝표지 줄 앞까지 · 머리 PDF쪽 밖은 안 봄 · 쪽 꼬리 뺌.
쪽을 넘거나(다음 쪽 맨 위) 한 쪽 안에서 「→」로 끊긴 표가 되풀이 찍은 머리 줄(규칙 4-5)은 스스로 뺌.
셋을 다 갖춘 줄만 뺌 — 하나라도 어긋나면 그 자리에서 빼기를 멈춤:
① 이번 항에서 이미 나온 글자와 공백 빼고 이어 붙음(줄 나눔은 안 가림 — 쪽마다 줄 묶임이 달라도 잡음).
새 항이 시작하면(「9-17-4 …」·「(7505) …」) 이미 본 글자를 잊음.
② 목·항 머리 모양이 아님 — 「1.」·「가.」·「①」·「[주]」·「<…>」 로 시작하는 줄은 새 표·새 목의 첫 줄.
③ md 가 못 가진 글자 몫 안임(`body` 를 줬을 때) — PDF 가 md 보다 더 가진 만큼만 뺌.
"""
first, _, last = head["PDF쪽"].partition("~")
first, last = int(first), int(last or first)
start_page, start_row = locate(doc, head["시작표지"], first, slack=0)
end_page, end_row = last + 1, 0
if head["끝표지"] and head["끝표지"] != "끝":
try:
end_page, end_row = locate(doc, head["끝표지"], last, slack=1)
except ValueError:
pass
raw: list[tuple[int, str, bool]] = [] # (쪽, 줄, 이어진 자리의 첫 줄인가)
for number in range(start_page, min(end_page, last) + 1):
rows = [row.strip() for row in read_rows(doc[number - 1])]
if rows and _is_footer(doc[number - 1], rows[-1], footer):
rows[-1] = "" # 쪽 꼬리
if rows and _side_number(doc[number - 1], rows[0]):
rows[0] = "" # 눕힌 쪽 옆 띠 쪽 번호
fresh = number > start_page # 다음 쪽 맨 위
for index, row in enumerate(rows):
if (number, index) < (start_page, start_row) or (number, index) >= (end_page, end_row):
continue
if not row:
continue
if row == "→": # 한 쪽 안에서 표가 끊긴 자리
fresh = True
continue
raw.append((number, row, fresh))
fresh = False
budget = _repeat_budget([(n, r) for n, r, _ in raw], body) if body else None
gone, later = [False] * len(raw), []
since_head, at = "", 0
while at < len(raw):
number, row, fresh = raw[at]
if fresh and (run := _repeat_run(raw, at, since_head)):
chars = sum((_chars(raw[k][1]) for k in run), collections.Counter())
if budget is None or not (chars - budget): # 통째로 몫에 드는 것부터 뺌
if budget is not None:
budget -= chars
for k in run:
gone[k] = True
at = run[-1] + 1
continue
later.append(run) # 몫이 모자란 것은 뒤로 미룸
if _ITEM_HEAD.match(row):
since_head = ""
since_head += re.sub(r"\s+", "", row)
at += 1
for run in later: # 남은 몫으로 앞 토막만 더 뺌 — 표 머리 뒤에 몸 줄이 딸려 잡힌 자리
for k in run:
chars = _chars(raw[k][1])
if chars - budget:
break
budget -= chars
gone[k] = True
return [(number, row) for (number, row, _), out in zip(raw, gone) if not out]
def _repeat_run(raw: list[tuple[int, str, bool]], at: int, since_head: str) -> list[int]:
"""이어진 자리에서 되풀이 머리로 볼 줄 차례 — 이미 나온 글자에 이어 붙는 줄만, 첫 다른 줄 앞까지."""
run, trial = [], ""
for k in range(at, len(raw)):
_, row, fresh = raw[k]
if (k > at and fresh) or _NOT_REPEAT.match(row):
break
grown = trial + re.sub(r"\s+", "", row)
if not grown or grown not in since_head:
break
trial = grown
run.append(k)
return run
def _split_off(lines: list[tuple[int, str]]) -> tuple[list[tuple[int, str]], list[tuple[int, str]]]:
"""(셀 줄, 글자층 밖 줄) — PDF 는 사용자 영역 글리프 · md 는 `글자층 밖` 으로 감싼 글자를 떼어 냄."""
kept, off, inside = [], [], False
for at, line in lines:
keep, gone = [], []
for ch in line:
if ch in (_OFF_OPEN, _OFF_CLOSE):
inside = ch == _OFF_OPEN
elif inside or _PUA.match(ch):
# 뗀 자리는 가름 — 분수선 양쪽 수가 붙지 않게(「174」·「2」 ≠ 「1742」)
keep.append(" ")
gone.append(ch)
else:
keep.append(ch)
kept.append((at, "".join(keep)))
if "".join(gone).strip():
off.append((at, "".join(gone)))
return kept, off
def _tight(lines: list[tuple[int, str]]) -> tuple[str, list[int]]:
"""공백 뗀 글자 줄 + 글자마다 어디서 왔나(쪽 또는 md 줄) · 호환 한자는 통합 한자로."""
text, where = [], []
for at, line in lines:
for ch in re.sub(r"\s", "", line):
text.append(_same_han(ch))
where.append(at)
return "".join(text), where
def _numbers(lines: list[tuple[int, str]]) -> tuple[collections.Counter, dict[str, list[int]]]:
counts, where = collections.Counter(), collections.defaultdict(list)
for at, line in lines:
for token in _NUM.findall(line):
counts[token] += 1
where[token].append(at)
return counts, where
def compare(pdf: list[tuple[int, str]], md: list[tuple[int, str]]) -> dict:
"""① 수(갯수까지) ② 글자(공백 뗌 · 갯수까지) — 양방향. 글자층 밖(사용자 영역 글리프 · 감싼 받아쓰기)은 빼고 따로 셈."""
pdf, pdf_off = _split_off(pdf)
md, md_off = _split_off(md)
pdf_n, pdf_at = _numbers(pdf)
md_n, md_at = _numbers(md)
pdf_t, pdf_where = _tight(pdf)
md_t, md_where = _tight(md)
missing_c = collections.Counter(pdf_t) - collections.Counter(md_t)
extra_c = collections.Counter(md_t) - collections.Counter(pdf_t)
spots = []
if missing_c or extra_c: # 자리 — 차례대로 맞대 어긋난 토막(갯수 차가 난 글자가 든 것만)
matcher = difflib.SequenceMatcher(None, pdf_t, md_t, autojunk=False)
for op, i1, i2, j1, j2 in matcher.get_opcodes():
gone, added = pdf_t[i1:i2], md_t[j1:j2]
if op == "equal" or not (set(gone) & set(missing_c) or set(added) & set(extra_c)):
continue
spots.append(
{
"pdf": pdf_where[min(i1, len(pdf_where) - 1)] if pdf_where else None,
"md": md_where[min(j1, len(md_where) - 1)] if md_where else None,
"context": pdf_t[max(0, i1 - 8) : i1],
"pdf_only": gone,
"md_only": added,
}
)
return {
"num_missing": {k: (v, pdf_at[k][:v]) for k, v in (pdf_n - md_n).items()},
"num_extra": {k: (v, md_at[k][:v]) for k, v in (md_n - pdf_n).items()},
"char_missing": missing_c,
"char_extra": extra_c,
"spots": spots,
"off_pdf": (
sum(len(re.sub(r"\s", "", t)) for _, t in pdf_off),
sorted({a for a, _ in pdf_off}),
),
"off_md": sum(len(re.sub(r"\s", "", t)) for _, t in md_off),
}
#: 자간 벌린 수 — md 주석 `<!-- 자간 벌린 수: 「1 . 3」→「1.3」 · 「3 0 4」→「304」 -->` (규칙 3-13).
#: 짐작으로 붙이지 않고 **적힌 글자열만** PDF 쪽에서 붙여 셈 · 오른쪽은 왼쪽에서 공백만 뺀 것이어야 함.
_SPACED = re.compile(r"<!--\s*자간 벌린 수:(.*?)-->", re.S)
_SPACED_PAIR = re.compile(r"「([^」]+)」\s*→\s*「([^」]+)」")
#: 글자층 붙은 수 — 자간 벌린 수의 거꾸로. 글자층이 여러 칸 값을 공백 없이 한 덩이로 담은 것
#: md 주석 `<!-- 글자층 붙은 수: 「0.3350.335」→「0.335 0.335」 -->` · 왼쪽은 오른쪽에서 공백만 뺀 것이어야 함.
_GLUED = re.compile(r"<!--\s*글자층 붙은 수:(.*?)-->", re.S)
#: 기계검사 예외 — md 주석 `<!-- 기계검사 예외: 되풀이 머리 -->` (규칙 6장 · 머리 바로 아래 한 줄).
#: 도구가 스스로 못 가리는 되풀이 머리를 쓴 절 — 사람이 쪽 그림으로 대조하고 손으로 붙임.
#: 붙이면 ①② 가 어긋나도 「예외(되풀이 머리)」 로 적고 확정 파일이면 지문도 적음.
_EXEMPT = re.compile(r"<!--\s*기계검사 예외:\s*되풀이 머리\s*-->")
def _rewrite(pdf: list[tuple[int, str]], body: str, mark: re.Pattern, ok):
"""(PDF 줄, 고친 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자열만 PDF 줄에서 바꿈(PDF 에 있고 ok 일 때만)."""
done, bad = 0, []
for block in mark.findall(body):
for old, new in _SPACED_PAIR.findall(block):
hits = sum(line.count(old) for _, line in pdf)
if not hits or not ok(old, new):
bad.append(old)
continue
pdf = [(at, line.replace(old, new)) for at, line in pdf]
done += hits
return pdf, done, bad
def join_spaced_numbers(pdf: list[tuple[int, str]], body: str):
"""(PDF 줄, 붙인 곳 수, 못 쓴 글자열) — md 주석에 적힌 자간 벌린 수만 PDF 줄에서 붙임."""
return _rewrite(pdf, body, _SPACED, lambda spaced, tight: re.sub(r"\s", "", spaced) == tight)
def split_glued_numbers(pdf: list[tuple[int, str]], body: str):
"""(PDF 줄, 가른 곳 수, 못 쓴 글자열) — md 주석에 적힌 글자층 붙은 수만 PDF 줄에서 가름."""
return _rewrite(
pdf, body, _GLUED, lambda glued, split: glued != split and re.sub(r"\s", "", split) == glued
)
def verdict(result: dict) -> str:
counts = (
sum(v for v, _ in result["num_missing"].values()),
sum(v for v, _ in result["num_extra"].values()),
sum(result["char_missing"].values()),
sum(result["char_extra"].values()),
)
joined, bad = result.get("spaced", (0, []))
split, bad_glued = result.get("glued", (0, []))
clean = not any(counts) and not bad and not bad_glued
word = "통과" if clean else ("예외(되풀이 머리)" if result.get("exempt") else "불통")
line = (
f"{word} · ①수 결손 {counts[0]} 허구 {counts[1]} · ②글자 빠짐 {counts[2]} 더함 {counts[3]}"
)
glyphs, pages = result.get("off_pdf", (0, []))
if glyphs or result.get("off_md"): # ③ 이 그림으로 그 식을 꼭 보게
where = f"({', '.join(map(str, pages))}쪽)" if pages else ""
line += f" · 글자층 밖 글리프 {glyphs}개{where} · 받아씀 {result.get('off_md', 0)}자"
if joined: # ③ 이 그림으로 그 수를 꼭 보게
line += f" · 자간 벌린 수 {joined}곳"
if bad:
line += " · 자간 벌린 수 못 씀 " + " ".join(f"「{s}」" for s in bad)
if split: # ③ 이 그림으로 칸마다 값을 꼭 보게
line += f" · 글자층 붙은 수 {split}곳"
if bad_glued:
line += " · 글자층 붙은 수 못 씀 " + " ".join(f"「{s}」" for s in bad_glued)
return line
def passed(line: str) -> str:
"""기계검사 한 줄이 받아들일 만한가 — 「통과」 또는 「예외(되풀이 머리)」."""
return line.startswith(("통과", "예외"))
def fingerprint(text: str) -> str:
"""확정지문 — 줄끝을 LF 로 고쳐 셈(git 이 CRLF 로 풀어도 같게) · 도구가 적는 두 칸(기계검사·확정지문)은 뺌."""
lines = text.replace("\r\n", "\n").split("\n")
kept = [ln for ln in lines if not ln.startswith(("기계검사:", "확정지문:"))]
return hashlib.sha256("\n".join(kept).encode("utf-8")).hexdigest()
def set_head(text: str, field: str, value: str) -> str:
head, body = split_head(text)
head = re.sub(rf"(?m)^{field}:.*$", f"{field}: {value}".rstrip(), head, count=1)
return head + "\n" + body
def check_file(path: Path) -> tuple[dict, str]:
"""(대조 결과, 한 줄 판정) — 파일은 안 고침."""
import pymupdf
_, body = split_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"))
book = book_of(path)
with pymupdf.open(book["pdf"]) as doc:
pdf = pdf_lines(doc, read_head(path), book["footer"], body)
pdf, joined, bad = join_spaced_numbers(pdf, body)
pdf, split, bad_glued = split_glued_numbers(pdf, body)
result = compare(pdf, md_lines(body))
result["spaced"] = (joined, bad)
result["glued"] = (split, bad_glued)
result["exempt"] = bool(_EXEMPT.search(body))
return result, verdict(result)
def check(path: Path) -> int:
"""검사하고 머리 「기계검사」 칸에 적음 · 상태 `확정` 이고 통과인데 확정지문이 비었으면 지문도 적음.
⚠ 있는 확정지문은 안 고침 — 확정 뒤 바뀐 파일을 지문을 다시 적어 덮으면 시험이 못 잡음.
"""
result, line = check_file(path)
print(f"{path.name}: {line}")
for token, (count, at) in sorted(result["num_missing"].items()):
print(f" ① 결손 {token} ×{count} (PDF {', '.join(map(str, at))}쪽)")
for token, (count, at) in sorted(result["num_extra"].items()):
print(f" ① 허구 {token} ×{count} (md 몸 {', '.join(map(str, at))}줄)")
for spot in result["spots"][:40]:
print(
f" ② PDF {spot['pdf']}쪽 · md 몸 {spot['md']}줄 · …{spot['context']}"
f" [PDF만 「{spot['pdf_only']}」 · md만 「{spot['md_only']}」]"
)
text = set_head(path.read_text(encoding="utf-8").replace("\r\n", "\n"), "기계검사", line)
head = read_head(path)
if head.get("상태") == "확정" and passed(line) and not head.get("확정지문"):
text = set_head(text, "확정지문", fingerprint(text))
path.write_text(text, encoding="utf-8", newline="\n")
return 0 if passed(line) else 1
# ── pages · status ─────────────────────────────────────────────────────
PAGES_DIR = ROOT / "tmp/pum_pages"
+8 -202
View File
@@ -660,208 +660,6 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
return {"줄": lines, **sums, "계": sum(sums.values()), "중간": _shown(env)}
# ── 검사 ──────────────────────────────────────────────────────────────
def _choices(row: dict, name: str, master: Master):
"""이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None."""
for spec in row.get("입력", []):
if spec["이름"] == name:
return spec.get("고르기")
for step in row.get("중간", []):
if step["이름"] == name:
node = parse(step["식"])
if node[0] == "field" and node[1][0] == "ref":
out = set()
for ref in _expand(row, node[1][1], master) or []:
try:
out.add(str(master.get(ref)["값"][node[2]]))
except (FormulaError, KeyError, TypeError):
pass
return sorted(out)
return None
def _expand(row: dict, template: str, master: Master):
"""원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
holes = _HOLE.findall(template)
options = [_choices(row, h, master) for h in holes]
if any(o is None for o in options):
return None
return [
fill(template, {h: v for h, v in zip(holes, combo)})
for combo in itertools.product(*options)
]
def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set):
for part in walk(node):
kind = part[0]
if kind == "name" and part[1] not in known:
out.append(f"{where} · 모르는 이름 「{part[1]}」")
if kind in ("ref", "find", "logic"):
for hole in _HOLE.findall(part[1]):
if hole not in known:
out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님")
if kind == "ref":
refs = _expand(row, part[1], master) or []
for ref in refs:
try:
link = master.get(ref).get("연결")
except FormulaError as e:
out.append(f"{where} · {e}")
continue
if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known:
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요")
if (
isinstance(link, dict)
and link.get("계약종별") == "입력"
and TARIFF not in known
):
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요")
if kind == "field" and part[1][0] == "ref":
for ref in _expand(row, part[1][1], master) or []:
try:
value = master.get(ref).get("값")
except FormulaError:
continue
if not isinstance(value, dict) or part[2] not in value:
out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음")
if kind == "find":
if mk.id_of(part[1]) not in TABLE_IDS:
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
continue
try:
table = master.get(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
for cond, _ in part[2]:
if cond not in table.get("조건", {}):
out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음")
if kind == "field" and part[1][0] == "find":
try:
table = master.get(part[1][1])
except FormulaError:
continue
if part[2] not in table.get("값칸", {}):
out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음")
if kind == "logic":
calls.add(part[1])
try:
target = master.logic(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
names = {s["이름"] for s in target.get("입력", [])}
given = {k for k, _ in part[2]}
for extra in sorted(given - names):
out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음")
for lack in sorted(names - given):
out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌")
if kind == "field" and part[1][0] == "logic":
try:
target = master.logic(part[1][1])
except FormulaError:
continue
if "결과" in target:
out.append(
f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
)
elif part[2] not in BUNDLE:
out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함")
def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
"""(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력."""
out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}"
known = {s["이름"] for s in row.get("입력", [])}
def one(text, where, names):
try:
_check_node(row, parse(text), names, master, where, out, calls)
except FormulaError as e:
out.append(f"{where} · 식 못 읽음: {e}")
for step in row.get("중간", []):
one(step["식"], f"{key} 중간 {step['이름']}", known)
known = known | {step["이름"]}
if "결과" in row:
one(row["결과"]["식"], f"{key} 결과", known)
for item in row.get("호표", []):
where = f"{key} 호표 {item.get('이름', '')}"
one(item["수량"], where + " 수량", known)
if item.get("종류") not in ("인력", "재료", "기계", "로직"):
out.append(f"{where} · 종류 「{item.get('종류')}」")
if item.get("비목") not in (*COST_ITEMS, None) or (
item.get("종류") != "로직" and "비목" not in item
):
out.append(f"{where} · 비목 「{item.get('비목')}」")
if isinstance(item["요소"], dict): # 재료 고르기 조건
if item.get("종류") != "재료":
out.append(f"{where} · 고르기 조건은 종류 「재료」 만")
# 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄
for value in item["요소"].values():
for hole in _HOLE.findall(value) if isinstance(value, str) else []:
if hole not in known:
out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님")
out += check_pick(master, where + " 요소", item["요소"])
continue
one(item["요소"], where + " 요소", known)
group = mk.GROUP_OF.get(mk.id_of(str(item["요소"])))
if item.get("종류") != "로직" and group != item.get("종류"):
out.append(f"{where} · 요소 그룹이 종류와 다름")
names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만)
for extra in row.get("덧줄", []):
one(extra["식"], f"{key} 덧줄 {extra['이름']}", names)
names = names | {extra["이름"]}
if extra.get("비목") not in COST_ITEMS:
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
out += _check_cut(row, key)
return out, calls
def _check_cut(row: dict, key: str) -> list[str]:
"""끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함)."""
rule = row.get("끝수")
if rule is None or isinstance(rule, str):
return []
if not isinstance(rule, dict):
return [f"{key} 끝수 · 묶음도 글자도 아님"]
out = []
unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"}
if unknown:
out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}")
on = rule.get("대상")
if on not in ROUND_ON:
out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}")
elif ("결과" in row) != (on == "결과"):
out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음")
if rule.get("방법") not in ROUND_WAYS:
out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}")
place = rule.get("자리", 0)
if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6:
out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수")
return out
def find_loops(graph: dict[str, set]) -> list[list[str]]:
"""로직 부르기의 돌고 도는 길."""
loops, state = [], {}
def visit(node, path):
state[node] = 1
for nxt in sorted(graph.get(node, ())):
if state.get(nxt) == 1:
loops.append(path[path.index(nxt) :] + [nxt])
elif nxt not in state:
visit(nxt, path + [nxt])
state[node] = 2
for node in sorted(graph):
if node not in state:
visit(node, [node])
return loops
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
from master_material import ( # noqa: E402, F401
LINKED,
@@ -878,3 +676,11 @@ from master_material import ( # noqa: E402, F401
pick,
)
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
from master_formula_check import ( # noqa: E402, F401 — 검사는 나눈 파일 · 이름은 여기서 그대로
_check_cut,
_check_node,
_choices,
_expand,
check_logic,
find_loops,
)
@@ -0,0 +1,227 @@
# -*- coding: utf-8 -*-
"""마스터 로직 식 — 검사 (`master_formula` 에서 나눔 · 밖에서는 `master_formula` 로 부름).
`master_formula` 끝에서만 읽힘 — 먼저 읽으면 서로 부르다 막힘.
"""
from __future__ import annotations
import itertools
from decimal import Decimal
import master_keys as mk
from master_formula import (
_HOLE,
BUNDLE,
COST_ITEMS,
ROUND_ON,
ROUND_WAYS,
TABLE_IDS,
FormulaError,
fill,
parse,
walk,
)
from master_material import REGION, TARIFF, check_pick
# ── 검사 ──────────────────────────────────────────────────────────────
def _choices(row: dict, name: str, master: Master):
"""이름이 가질 수 있는 값 — 고르기 입력, 또는 고르기 입력만 끼운 요소 칸인 중간. 모르면 None."""
for spec in row.get("입력", []):
if spec["이름"] == name:
return spec.get("고르기")
for step in row.get("중간", []):
if step["이름"] == name:
node = parse(step["식"])
if node[0] == "field" and node[1][0] == "ref":
out = set()
for ref in _expand(row, node[1][1], master) or []:
try:
out.add(str(master.get(ref)["값"][node[2]]))
except (FormulaError, KeyError, TypeError):
pass
return sorted(out)
return None
def _expand(row: dict, template: str, master: Master):
"""원문번호 틀의 {이름} 을 고를 수 있는 값으로 모두 끼움 — 못 정하면 None."""
holes = _HOLE.findall(template)
options = [_choices(row, h, master) for h in holes]
if any(o is None for o in options):
return None
return [
fill(template, {h: v for h, v in zip(holes, combo)})
for combo in itertools.product(*options)
]
def _check_node(row, node, known: set, master: Master, where: str, out: list, calls: set):
for part in walk(node):
kind = part[0]
if kind == "name" and part[1] not in known:
out.append(f"{where} · 모르는 이름 「{part[1]}」")
if kind in ("ref", "find", "logic"):
for hole in _HOLE.findall(part[1]):
if hole not in known:
out.append(f"{where} · 원문번호 {{{hole}}} 가 입력·중간 아님")
if kind == "ref":
refs = _expand(row, part[1], master) or []
for ref in refs:
try:
link = master.get(ref).get("연결")
except FormulaError as e:
out.append(f"{where} · {e}")
continue
if isinstance(link, dict) and link.get("지역") == "입력" and REGION not in known:
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{REGION}」 필요")
if (
isinstance(link, dict)
and link.get("계약종별") == "입력"
and TARIFF not in known
):
out.append(f"{where} · 「{master.label(ref)}」 는 입력 「{TARIFF}」 필요")
if kind == "field" and part[1][0] == "ref":
for ref in _expand(row, part[1][1], master) or []:
try:
value = master.get(ref).get("값")
except FormulaError:
continue
if not isinstance(value, dict) or part[2] not in value:
out.append(f"{where} · 「{master.label(ref)}」 에 칸 「{part[2]}」 없음")
if kind == "find":
if mk.id_of(part[1]) not in TABLE_IDS:
out.append(f"{where} · 찾기 대상 「{part[1]}」 이 표 아님")
continue
try:
table = master.get(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
for cond, _ in part[2]:
if cond not in table.get("조건", {}):
out.append(f"{where} · 「{master.label(part[1])}」 에 조건 「{cond}」 없음")
if kind == "field" and part[1][0] == "find":
try:
table = master.get(part[1][1])
except FormulaError:
continue
if part[2] not in table.get("값칸", {}):
out.append(f"{where} · 「{master.label(part[1][1])}」 에 값 칸 「{part[2]}」 없음")
if kind == "logic":
calls.add(part[1])
try:
target = master.logic(part[1])
except FormulaError as e:
out.append(f"{where} · {e}")
continue
names = {s["이름"] for s in target.get("입력", [])}
given = {k for k, _ in part[2]}
for extra in sorted(given - names):
out.append(f"{where} · 「{master.label(part[1])}」 에 입력 「{extra}」 없음")
for lack in sorted(names - given):
out.append(f"{where} · 「{master.label(part[1])}」 입력 「{lack}」 안 줌")
if kind == "field" and part[1][0] == "logic":
try:
target = master.logic(part[1][1])
except FormulaError:
continue
if "결과" in target:
out.append(
f"{where} · 돈 아닌 로직 「{master.label(part[1][1])}」 은 수 하나 — 칸 「{part[2]}」 못 씀"
)
elif part[2] not in BUNDLE:
out.append(f"{where} · 로직 결과 칸 「{part[2]}」 은 {BUNDLE} 가운데 하나여야 함")
def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
"""(문제 목록, 부르는 로직 키) — 변수 · 찾기 조건 · 로직 입력."""
out, calls, key = [], set(), f"{row.get('키')} {row.get('원문번호')}"
known = {s["이름"] for s in row.get("입력", [])}
def one(text, where, names):
try:
_check_node(row, parse(text), names, master, where, out, calls)
except FormulaError as e:
out.append(f"{where} · 식 못 읽음: {e}")
for step in row.get("중간", []):
one(step["식"], f"{key} 중간 {step['이름']}", known)
known = known | {step["이름"]}
if "결과" in row:
one(row["결과"]["식"], f"{key} 결과", known)
for item in row.get("호표", []):
where = f"{key} 호표 {item.get('이름', '')}"
one(item["수량"], where + " 수량", known)
if item.get("종류") not in ("인력", "재료", "기계", "로직"):
out.append(f"{where} · 종류 「{item.get('종류')}」")
if item.get("비목") not in (*COST_ITEMS, None) or (
item.get("종류") != "로직" and "비목" not in item
):
out.append(f"{where} · 비목 「{item.get('비목')}」")
if isinstance(item["요소"], dict): # 재료 고르기 조건
if item.get("종류") != "재료":
out.append(f"{where} · 고르기 조건은 종류 「재료」 만")
# 조건 칸의 {이름} 은 계산 때 채워짐 — 그 이름이 실제 입력·중간인지만 봄
for value in item["요소"].values():
for hole in _HOLE.findall(value) if isinstance(value, str) else []:
if hole not in known:
out.append(f"{where} 요소 · 고르기 조건 {{{hole}}} 가 입력·중간 아님")
out += check_pick(master, where + " 요소", item["요소"])
continue
one(item["요소"], where + " 요소", known)
group = mk.GROUP_OF.get(mk.id_of(str(item["요소"])))
if item.get("종류") != "로직" and group != item.get("종류"):
out.append(f"{where} · 요소 그룹이 종류와 다름")
names = known | set(COST_ITEMS) | {"줄"} # 「줄」 = 호표 줄 금액 묶음(덧줄에서만)
for extra in row.get("덧줄", []):
one(extra["식"], f"{key} 덧줄 {extra['이름']}", names)
names = names | {extra["이름"]}
if extra.get("비목") not in COST_ITEMS:
out.append(f"{key} 덧줄 {extra['이름']} · 비목 「{extra.get('비목')}」")
out += _check_cut(row, key)
return out, calls
def _check_cut(row: dict, key: str) -> list[str]:
"""끝수 — 묶음이면 칸·대상·방법·자리 · 글자는 설명(검사 안 함)."""
rule = row.get("끝수")
if rule is None or isinstance(rule, str):
return []
if not isinstance(rule, dict):
return [f"{key} 끝수 · 묶음도 글자도 아님"]
out = []
unknown = set(rule) - {"대상", "자리", "방법", "출처", "비고"}
if unknown:
out.append(f"{key} 끝수 · 모르는 칸 {sorted(unknown)}")
on = rule.get("대상")
if on not in ROUND_ON:
out.append(f"{key} 끝수 · 대상 「{on}」 — {' · '.join(ROUND_ON)}")
elif ("결과" in row) != (on == "결과"):
out.append(f"{key} 끝수 · 대상 「{on}」 이 로직 갈래와 안 맞음")
if rule.get("방법") not in ROUND_WAYS:
out.append(f"{key} 끝수 · 방법 「{rule.get('방법')}」 — {' · '.join(ROUND_WAYS)}")
place = rule.get("자리", 0)
if not isinstance(place, (int, Decimal)) or place != int(place) or not 0 <= int(place) <= 6:
out.append(f"{key} 끝수 · 자리 「{place}」 — 0∼6 정수")
return out
def find_loops(graph: dict[str, set]) -> list[list[str]]:
"""로직 부르기의 돌고 도는 길."""
loops, state = [], {}
def visit(node, path):
state[node] = 1
for nxt in sorted(graph.get(node, ())):
if state.get(nxt) == 1:
loops.append(path[path.index(nxt) :] + [nxt])
elif nxt not in state:
visit(nxt, path + [nxt])
state[node] = 2
for node in sorted(graph):
if node not in state:
visit(node, [node])
return loops