유효한 xlsx가 반드시 xl/worksheets/sheet1.xml을 포함해야 하는 것은 아닙니다. 델파이와 C++Builder용 네이티브 Excel 스프레드시트 컴포넌트인 HotXLS는 이름을 추측하는 대신 OPC 관계 그래프를 통해 모든 파트를 찾아냅니다. ISO/IEC 29500-2는 파트가 _rels/.rels로부터 도달 가능하다는 것만 보장할 뿐, 관례적인 경로에 있다는 것은 결코 보장하지 않기 때문입니다
내 파서가 유효한 xlsx에서 실패하는 이유는 무엇인가
여러분이 외워둔 파트 이름은 포맷의 요구사항이 아니라 한 생산자의 관례이기 때문입니다. 여러분이 지금까지 하드코딩해온 모든 경로, xl/workbook.xml, xl/sharedStrings.xml, xl/styles.xml, xl/worksheets/sheetN.xml은 데스크톱 Excel 작성기가 우연히 방출하는 것일 뿐입니다. 준수하는 패키지는 워크북을 office/book.xml에, 첫 워크시트를 xl/custom/data-sheet.xml에 두고도 여전히 합법적인 SpreadsheetML일 수 있습니다. 관계가 그곳을 가리키는 한 말입니다. 이것이 자체 제작 리더가 Excel, LibreOffice, Numbers 모두 아무 불평 없이 여는 파일에 대해 "sheet1.xml을 찾을 수 없다"고 보고하는 가장 흔한 단일 이유입니다
이렇게 하는 생산자는 이색적이지 않습니다. 서버 측 보고서 생성기는 템플릿 패키지를 재사용하며 원래의 레이아웃을 유지합니다. 두 워크북을 병합하는 내보내기 파이프라인은 시트 번호를 다시 매기면서 빈틈을 남기므로, 다섯 시트짜리 워크북이 sheet1, sheet2, sheet4, sheet7, sheet9를 갖게 됩니다. 시트를 제거하는 도구가 항상 생존자의 번호를 다시 매기는 것은 아닙니다. 이런 모든 경우에서 인덱스 기반 추측 xl/worksheets/sheet + IntToStr(i + 1) + .xml은 조용히 잘못된 시트를 읽거나 아무것도 읽지 않습니다. 이는 예외보다 더 나쁩니다. 워크북이 로드되고 숫자가 틀리기 때문입니다. 아래의 최소 패키지는 이 문제 전체를 운동시키며, 이는 HotXLS가 회귀 테스트하는 형태입니다
<!-- _rels/.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
Target="office/book.xml"/>
</Relationships>
<!-- office/_rels/book.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId42"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"
Target="../xl/custom/data-sheet.xml"/>
</Relationships>
<!-- xl/custom/_rels/data-sheet.xml.rels -->
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="note7"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
Target="../notes/review.xml"/>
</Relationships>
ISO/IEC 29500-2가 실제로 보장하는 것은 무엇인가
위치가 아니라 도달 가능성을 보장합니다. ISO/IEC 29500-2는 표준의 Open Packaging Conventions 파트이며, 그 관계 조항은 정확히 하나의 고정된 진입점을 정의합니다: _rels/.rels의 패키지 관계 파트입니다. 그로부터 Type이 http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument인 관계를 따라가면 워크북 파트에 도달하며, 다른 모든 파트는 그 파트 자신의 관계 파트를 읽고 타입이 지정된 엣지를 바깥쪽으로 따라감으로써 발견됩니다
같은 표준의 두 가지 추가 규칙이 실질적인 작업을 합니다. 파트 이름 지정 조항은 관계 파트가 사는 위치를 고정합니다: <folder>/<name>에 있는 파트의 경우, 그 관계는 <folder>/_rels/<name>.rels에 있으며, 패키지 루트에 있는 파트의 경우 그 폴더는 단순히 _rels/입니다. 관계 마크업 조항은 TargetMode="External"이 패키지 바깥을 가리키는 것으로 표시하지 않는 한, Target이 일반적인 RFC 3986의 의미에서 소스 파트의 URI에 대해 해석되는 URI 참조라고 명시합니다. 소스 상대 해석은 모두가 건너뛰는 단계이며, 같은 리터럴 ../notes/review.xml이 xl/custom/_rels/data-sheet.xml.rels 안에서는 한 가지를 의미하고 한 폴더 더 깊은 rels 파일 안에서는 완전히 다른 것을 의미하는 이유입니다. 논리 모델과 디스크의 바이트 사이에 마지막 주름이 하나 있습니다: 논리 모델의 파트 이름은 절대적이며 슬래시로 시작하지만, ZIP 물리 매핑 조항은 파트 이름을 ZIP 항목 이름으로 바꿀 때 그 슬래시를 제거합니다. 그래서 이를 잊은 리졸버는 아카이브에서 /xl/sharedStrings.xml을 찾다가 아무것도 찾지 못합니다
XlsxResolveRelationshipTarget 내부
HotXLS는 전체 해석 규칙을 lxHandleX.pas에 function XlsxResolveRelationshipTarget(const OwnerPartName, Target: WideString): WideString로 선언된 함수 하나, XlsxResolveRelationshipTarget에 집중시킵니다. 소스 파트의 ZIP 항목 이름과 원시 Target 속성을 받아서, 아카이브에 곧바로 건넬 준비가 된 선행 슬래시 없는 ZIP 항목 이름을 반환합니다. 빈 OwnerPartName을 전달하면 패키지 루트에 대해 해석되는데, 이는 정확히 패키지 관계 파트에 필요한 것입니다. 연산의 순서는 개별 단계보다 더 중요합니다: 일부 생산자가 Target에 Windows 구분자를 쓰기 때문에 역슬래시가 먼저 슬래시로 정규화됩니다. #로 도입된 조각은 경로 처리 전에 잘려나가므로, ../charts/chart1.xml#Sheet1은 존재하지 않는 아카이브 항목이 아니라 파트 이름으로 해석됩니다. 그런 다음에야 함수는 절대와 상대를 구분합니다
// Normalization core, as implemented in lxHandleX.pas.
combined := StringReplace(Target, '\', '/', [rfReplaceAll]);
p := Pos('#', combined);
if p > 0 then
combined := Copy(combined, 1, p - 1);
if (combined <> '') and (combined[1] = '/') then
Delete(combined, 1, 1) // package-absolute: strip the slash only
else
begin
p := LastDelimiter('/', String(OwnerPartName));
if p > 0 then
baseName := Copy(OwnerPartName, 1, p)
else
baseName := '';
combined := baseName + combined; // relative to the source part folder
end;
source.StrictDelimiter := True; // '/' only, no quote or space handling
source.Delimiter := '/';
source.DelimitedText := String(combined);
for i := 0 to source.Count - 1 do
begin
segment := WideString(source[i]);
if (segment = '') or (segment = '.') then
Continue; // empty and dot segments vanish
if segment = '..' then
begin
if parts.Count > 0 then
parts.Delete(parts.Count - 1); // pop, and never below the root
end
else
parts.Add(String(segment));
end;
세그먼트 루프는 단순한 스택 순회입니다: 빈 세그먼트와 .은 버려지고, ..은 한 단계를 팝하며, 패키지 루트를 벗어나려는 ..은 음수 인덱스나 ../로 시작하는 이름을 만드는 대신 흡수됩니다. StrictDelimiter := True 대입은 장식이 아닙니다. 이것 없이는 델파이의 TStringList가 공백을 구분자로 취급하고 따옴표 문자를 존중하는데, 이는 공백을 포함하는 파트 이름을 망가뜨립니다. 공백을 포함하는 파트 이름은 합법입니다
그래프 따라가기: 워크북, 워크시트, 드로잉
HotXLS는 TXLSXWorkbook.Open 경로에서 관계 파트를 세 계층 순회합니다. 패키지 계층은 XlsxFindOfficeDocumentPart가 처리하며, _rels/.rels를 읽고 officeDocument 타겟을 반환합니다. 워크북 계층은 워크북 관계 파트를 읽고 한 번에 두 개의 맵을 만듭니다: r:id 조회를 위한 식별자 맵과 싱글턴 파트를 위한 타입 맵입니다. 워크시트와 드로잉 계층은 ParseWorksheetRelsXml과 ParseDrawingRelsXml로 같은 패턴을 반복하며, 각각 자신의 파트 이름을 해석 기준으로 전달하므로 ../media/image3.png를 참조하는 드로잉이 올바른 블롭에 착지합니다
// Tier 1: the only fixed name in the whole format.
WorkbookPartName := XlsxFindOfficeDocumentPart(zip);
if WorkbookPartName = '' then
WorkbookPartName := 'xl/workbook.xml'; // legacy fallback
if not zip.Exists(WorkbookPartName) then
Exit;
// Tier 2: <folder>/_rels/<name>.rels for the workbook part itself.
relsName := XlsxRelationshipPartName(WorkbookPartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParsePartRelationshipsXml(relsStream, WorkbookPartName,
WorkbookTargetById, WorkbookTargetsByType);
finally
relsStream.Free;
end;
end;
// Typed singletons resolve by relationship type URI.
PartName := WorkbookTargetsByType.Values[XlsxRtSharedStrings];
if PartName = '' then
PartName := 'xl/sharedStrings.xml';
시트는 특히 타입 맵이 아니라 식별자 맵을 거쳐야 합니다. 워크북 파트의 <sheet> 요소는 r:id 속성을 가지며, 그 식별자만이 시트 이름을 파트에 묶어주는 유일한 것입니다. HotXLS는 ParseWorkbookXml 동안 그 식별자들을 수집하고 각각을 워크북 관계 맵에 대해 해석하며, 식별자가 없거나 해석 불가능할 때만 관례적인 번호 매김 이름으로 폴백합니다
// Tier 2b: r:id -> worksheet part, per sheet, in workbook order.
PartName := '';
if (i < SheetRelIds.Count) and (SheetRelIds[i] <> '') then
PartName := WorkbookTargetById.Values[SheetRelIds[i]];
if PartName = '' then
PartName := 'xl/worksheets/sheet' + IntToStr(i + 1) + '.xml';
SheetPartNames.Add(String(PartName));
// Tier 3: each worksheet resolves its own satellites against its own name.
relsName := XlsxRelationshipPartName(PartName);
if zip.Exists(relsName) then
begin
relsStream := zip.OpenFile(relsName);
try
ParseWorksheetRelsXml(relsStream, PartName,
FParRels[i], ParTableTargets[i], ParPartTargets[i]);
finally
relsStream.Free;
end;
end;
이후의 모든 것이 같은 메커니즘 위에서 굴러갑니다. 공유 문자열, 스타일, 테마, Microsoft 네임스페이스 타입 http://schemas.microsoft.com/office/2006/relationships/vbaProject 아래의 VBA 프로젝트, 외부 링크, 워크북 범위의 person 파트, 레거시 코멘트, 스레드형 코멘트, 코멘트 말풍선 기하 정보를 담은 VML 드로잉, 드로잉, 이미지, 차트, 표, 피벗 테이블 모두 해석된 타겟을 통해 자신의 바이트에 도달합니다. 특히 테마 파트는 정확히 위치를 찾아야 합니다. 그렇지 않으면 왕복 처리가 고객의 브랜드 팔레트를 조용히 기본 Office 테마로 덮어써버립니다. 이는 테마, extLst, calcChain의 무손실 XLSX 왕복에 관한 노트에서 다루는 실패 모드 중 하나입니다. 관계 읽기는 로딩이 그런 방식으로 단계화되는 이유이기도 합니다: 모든 아카이브 접근은 워크시트 XML이 파싱되기 전에 한 스레드에서 일어납니다. ZIP 아카이브의 인플레이트 상태는 스레드 안전하지 않기 때문이며, 이는 병렬 XLSX 파싱과 메모리 할당자에 관한 글에서 설명하는 제약입니다
중복된 rId가 타입 기반 라우팅을 깨뜨리는 이유는 무엇인가
나중에 온 잘못된 항목이 이전의 유효한 항목을 덮어써서 조회를 가로챌 수 있기 때문입니다. 관계 식별자는 관계 파트 내에서 고유해야 하지만, 잘못된 패키지는 그것을 재사용하며, 순진한 Values[Id] := 대입은 마지막에 쓴 것이 이깁니다. rId3이 처음에는 실제 워크시트를 가리키고 두 번째 rId3이 지원되지 않거나 빈 타겟을 가리킨다면, 마지막-쓰기-승리 방식은 워크시트를 잃습니다. 그래서 ParsePartRelationshipsXml은 두 조건을 가진 첫-번째-승리 규칙을 적용합니다: 해석된 타겟은 비어 있지 않아야 하고, 식별자는 아직 존재하지 않아야 합니다. 이 두 조건이 함께 있어야 안전해집니다. 비어 있지 않음 검사가 Target이 빠진 관계가 사용 가능한 것이 도착하기 전에 그 자리를 차지하는 것을 막아주기 때문입니다
if (TargetById <> nil) and (Id <> '') and (resolvedTarget <> '') and
(TargetById.IndexOfName(String(Id)) < 0) then
TargetById.Values[String(Id)] := String(resolvedTarget);
if (TargetsByType <> nil) and (relType <> '') and (resolvedTarget <> '') then
TargetsByType.Add(String(relType + '=' + resolvedTarget));
그 스니펫의 의도적인 비대칭에 주목하십시오. 식별자 맵은 첫-번째-승리 가드를 가진 진짜 맵이며, 타입 컬렉션은 type=target 쌍의 추가 전용 목록입니다. 그 구분은 하중을 견디는 부분입니다: 워크북은 정확히 하나의 공유 문자열 관계를 갖지만 많은 워크시트와 외부 링크 관계를 가지므로, Values[]를 통한 타입 조회는 싱글턴에 대해 첫 번째 일치를 반환하고, externalLink 같은 다중값 타입은 목록을 순회함으로써 열거됩니다
관계 추적이 멈추는 지점
정직한 경계는 깔끔한 이야기보다 더 중요합니다. HotXLS는 관계가 없을 때마다 관례적인 이름으로 폴백하므로, 손상되었거나 누락된 관계 파트를 가진 패키지도 Excel 레이아웃을 우연히 따르고 있다면 여전히 열립니다. 그 폴백은 두 번째 진실의 원천이 아니라 호환성 기능이며, 테스트 중에 생산자 버그를 가릴 수 있습니다. 알아둘 가치가 있는 세 가지 한계가 더 있습니다. TargetMode="External"로 표시된 타겟은 해석되지 않고 그대로 저장됩니다. 이는 하이퍼링크와 원격 워크북 URL을 담는 externalLinkPath 관계에는 올바르지만, 여러분이 돌려받는 값은 생산자가 쓴 그대로라는 뜻입니다. 드로잉 관계 파트를 통해 발견된 차트 파트는 식별자가 아니라 위치상으로 드로잉 앵커와 짝지어지므로, 특이한 앵커 순서는 차트 바인딩을 어긋나게 만들 수 있습니다. 그리고 lxDirectRead.pas의 스트리밍 다이렉트 리더는 xl/에 키를 둔 자체의 더 가벼운 경로 처리를 유지합니다. 그래서 여기서 설명한 전체 리졸버는 TXLSXWorkbook.Open과 GetSheetNames 진입점을 지배하지, 델파이용 스트리밍 다이렉트 리더에 관한 글에서 문서화된 저할당 스캔 경로를 지배하지는 않습니다
이것을 직접 구축하고 있다면, 가장 짧고 정확한 요약은 이렇습니다: 파트 이름을 결코 구성하지 말고, 항상 해석하십시오. _rels/.rels를 읽고, officeDocument를 따라가고, 모든 Target을 그것을 선언한 파트에 대해 해석하고, 시트는 r:id로 라우팅하십시오. 이름이 바뀐 파트, 불연속적인 시트 번호 매김, 중복된 관계 식별자에 대해 이미 테스트된 것을 원한다면, 여기서 설명한 리졸버는 HotXLS 델파이 스프레드시트 컴포넌트에 파싱하지 않는 파트를 그대로 유지하는 왕복 메커니즘과 함께 제공됩니다