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) return re.sub(r"[\s\u00b7\u2027]", "", s)
ENTRIES = [ from gen_md_entries import 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"건설공사표준시방서"]),
]
files = [p for p in sorted(BASE.rglob("*.md")) if not str(p.relative_to(BASE)).startswith("원문")] 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 from __future__ import annotations
import collections import collections
import difflib
import hashlib
import re
import sys import sys
import unicodedata
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from pum_md_base import ( # noqa: E402,F401 — 바탕(책 설정·줄 읽기·머리 찾기)은 pum_md_base.py 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, visual_text,
) )
_TOC_SKIP = {"장", "내 용", "페이지", "- 목 차 -"} from pum_md_plan import ( # noqa: E402,F401 — 차례 읽기 · 새 md 틀은 pum_md_plan.py
_TOC_ID = re.compile(r"제\d+장|\d+-\d+(?:-\d+)*|부록\d*") Entry,
_BAD_NAME = re.compile(r'[/:*?"<>|\\]') _BAD_NAME,
_LEADER = re.compile(r"\s*[·․…]{3,}.*$") _DIVISION,
#: 글자층이 없다고 볼 쪽 글자 수(쪽 번호만 남은 쪽) _EMPTY_PAGE_CHARS,
_EMPTY_PAGE_CHARS = 20 _INLINE,
_LEADER,
_MAX_PAGES,
@dataclass _REVISION,
class Entry: _SLOT,
"""차례 한 줄 — 장(`제1장`) · 절(`1-1`) · 항(`1-1-1`) · 부록.""" _TOC_ID,
_TOC_SKIP,
ident: str _blank,
name: str _body_rows,
page: int # 인쇄 쪽 _is_footer,
_side_number,
_span,
def toc_entries(doc, book: dict) -> list[Entry]: _span_of,
"""차례 쪽의 줄 → 차례 줄 목록(건설은 부문 줄 `공통부문` 도 끼움 · 쪽 0).""" claimed_elsewhere,
first, last = book["toc"] file_label,
raw = [ head_text,
line for number in range(first - 1, last) for line in doc[number].get_text().splitlines() name_of,
] parse_toc,
if book["toc_layout"] == "inline": parse_toc_inline,
return parse_toc_inline(raw) plan,
return parse_toc([line for line in raw if line.strip() not in _TOC_SKIP]) read_head,
safe,
toc_entries,
def parse_toc(raw: list[str]) -> list[Entry]: )
lines = [ from pum_md_check import ( # noqa: E402,F401 — 기계검사는 pum_md_check.py
t for t in (_LEADER.sub("", line).strip() for line in raw) if t _COMMENT,
] # 점선 뗌(「부록 ····」) _ESCAPED,
out, i = [], 0 _EXEMPT,
while i < len(lines): _GLUED,
if not _TOC_ID.fullmatch(lines[i]): _HOLD,
raise ValueError(f"차례 모양이 다름: {lines[i]!r}") _IMAGE,
j, name = i + 1, [] _ITEM_HEAD,
while j < len(lines) and not lines[j].isdigit(): _NOT_REPEAT,
name.append(lines[j]) _OFF_CLOSE,
j += 1 _OFF_MARK,
if j == len(lines): _OFF_OPEN,
raise ValueError(f"차례 쪽 번호 없음: {lines[i]}") _PUA,
out.append(Entry(lines[i], " ".join(name), int(lines[j]))) _SPACED,
i = j + 1 _SPACED_PAIR,
return out _TABLE_RULE,
_chars,
_numbers,
_INLINE = re.compile(r"^(\d+-\d+(?:-\d+)*)\s*(.*?)\s*[·․…]{3,}\s*(\d+)\s*$") _repeat_budget,
_DIVISION = re.compile(r"^\S+부문$") _repeat_run,
_rewrite,
_same_han,
def parse_toc_inline(raw: list[str]) -> list[Entry]: _split_off,
"""건설 차례 — 절·항은 「1-1 일반사항····3」 한 줄 · 장은 「제1장 / 적용기준 / 3」 · 부문은 「공통부문」 한 줄.""" _tight,
lines = [line.strip() for line in raw if line.strip()] book_of,
out, i = [], 0 check,
while i < len(lines): check_file,
line = lines[i] compare,
if found := _INLINE.match(line): fingerprint,
out.append(Entry(found[1], found[2], int(found[3]))) join_spaced_numbers,
elif re.fullmatch(r"제\d+장", line): md_lines,
out.append(Entry(line, lines[i + 1], int(lines[i + 2]))) passed,
i += 2 pdf_lines,
elif _DIVISION.match(line): set_head,
out.append(Entry(line, "", 0)) split_glued_numbers,
elif not ( split_head,
line.replace(" ", "") == "목차" or line.isdigit() verdict,
): # 쪽 머리 「목차」 · 차례 쪽 번호
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"|^[<※◦○●□■]" # <…> ※ ◦ ○ ● □ ■
) )
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 · status ─────────────────────────────────────────────────────
PAGES_DIR = ROOT / "tmp/pum_pages" 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)} 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 from master_material import ( # noqa: E402, F401
LINKED, LINKED,
@@ -878,3 +676,11 @@ from master_material import ( # noqa: E402, F401
pick, pick,
) )
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위 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