Skip to content

Commit 06f338f

Browse files
committed
Fix OPC part naming, leniency, and preserved relationships
1 parent 3aeaf2b commit 06f338f

9 files changed

Lines changed: 475 additions & 58 deletions

File tree

crates/duke-sheets-chart/src/drawing_part/write.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ pub struct PlannedRel {
133133
pub rel_type: String,
134134
pub target: String,
135135
pub external: bool,
136+
/// Round-tripped from a raw entry rather than generated here, so its
137+
/// target part may be absent if the source package lacked it.
138+
pub preserved: bool,
136139
}
137140

138141
/// Relationship-id assignments for a sheet's drawing part. Writer
@@ -233,6 +236,7 @@ pub fn plan_drawing_rels(
233236
rel_type: RT_IMAGE.to_string(),
234237
target: format!("../media/image{global_num}.{ext}"),
235238
external: false,
239+
preserved: false,
236240
});
237241
plan.image_rids.push(rid);
238242
}
@@ -255,6 +259,7 @@ pub fn plan_drawing_rels(
255259
rel_type: payload.0.clone(),
256260
target: payload.1.clone(),
257261
external: rel.external,
262+
preserved: true,
258263
});
259264
seen_raw.insert(fresh.clone(), payload);
260265
remap.push((rel.id.to_string(), fresh));
@@ -265,6 +270,7 @@ pub fn plan_drawing_rels(
265270
rel_type: payload.0.clone(),
266271
target: payload.1.clone(),
267272
external: rel.external,
273+
preserved: true,
268274
});
269275
seen_raw.insert(rel.id.to_string(), payload);
270276
}
@@ -302,6 +308,7 @@ pub fn plan_drawing_rels(
302308
rel_type: RT_CHART.to_string(),
303309
target: format!("../charts/chart{}.xml", chart_globals[chart_i]),
304310
external: false,
311+
preserved: false,
305312
});
306313
chart_i += 1;
307314
plan.chart_rids.push(rid);
@@ -313,6 +320,7 @@ pub fn plan_drawing_rels(
313320
rel_type: RT_CHART_EX.to_string(),
314321
target: format!("../charts/chartEx{}.xml", chartex_globals[chartex_i]),
315322
external: false,
323+
preserved: false,
316324
});
317325
chartex_i += 1;
318326
plan.chartex_rids.push(rid);

crates/duke-sheets-xlsx/src/opc/diagnostics.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,36 +28,62 @@ pub enum XlsxDiagnosticSeverity {
2828
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2929
#[non_exhaustive]
3030
pub enum XlsxDiagnosticCode {
31+
/// A ZIP entry or target breaks the OPC part-name rules.
3132
InvalidPartName,
33+
/// Two entries differ only by ASCII case, so they name one part.
3234
EquivalentPartName,
35+
/// One part name is a prefix of another, which OPC forbids.
3336
DerivablePartName,
37+
/// A part has no `Default` or `Override` content type.
3438
MissingContentType,
39+
/// An extension or part name is declared more than once.
3540
DuplicateContentType,
41+
/// `[Content_Types].xml` is unreadable or does not match its schema.
3642
MalformedContentType,
43+
/// A part that owns relationships has no `.rels` part.
3744
MissingRelationshipsPart,
45+
/// A `Relationship` element is malformed or wrongly namespaced.
3846
MalformedRelationship,
47+
/// Two relationships from one source share an `Id`.
3948
DuplicateRelationshipId,
49+
/// `TargetMode` is neither `Internal` nor `External`, or is miscased.
4050
UnknownTargetMode,
51+
/// An internal target could not be resolved to a part name.
4152
UnresolvedRelationshipTarget,
53+
/// An internal target resolved but names no part in the package.
4254
MissingRelationshipTarget,
55+
/// The package has no `/_rels/.rels`.
4356
MissingPackageRelationships,
57+
/// Package relationships contain no `officeDocument` relationship.
4458
MissingOfficeDocumentRelationship,
59+
/// More than one candidate Workbook part was found.
4560
AmbiguousOfficeDocumentRelationship,
61+
/// The Workbook part's content type is not a SpreadsheetML workbook.
4662
WorkbookContentTypeMismatch,
63+
/// A part's content type does not match its relationship type.
4764
PartContentTypeMismatch,
65+
/// A part was located by convention because relationships were unusable.
4866
CanonicalPartFallback,
67+
/// A valid sheet kind this library does not model, such as a dialog
68+
/// or macro sheet, was skipped.
4969
UnsupportedSheetType,
5070
}
5171

5272
/// A recoverable OPC package problem observed while reading an XLSX file.
5373
#[derive(Debug, Clone, PartialEq, Eq)]
5474
#[non_exhaustive]
5575
pub struct XlsxDiagnostic {
76+
/// Stable category, suitable for matching.
5677
pub code: XlsxDiagnosticCode,
78+
/// Whether data was skipped or a fallback was applied.
5779
pub severity: XlsxDiagnosticSeverity,
80+
/// Part that owns the problem, as an OPC part name.
5881
pub source_part: Option<String>,
82+
/// Relationship id, when the problem is relationship-scoped.
5983
pub relationship_id: Option<String>,
84+
/// Relationship target or part name the problem refers to.
6085
pub target: Option<String>,
86+
/// Human-readable explanation; not stable across versions.
6187
pub message: String,
6288
}
6389

crates/duke-sheets-xlsx/src/opc/package.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ impl<R: Read + Seek> OpcPackage<R> {
8989
continue;
9090
}
9191
};
92+
if let Some(problem) = part_name.conformance_error() {
93+
diagnostics.warning(
94+
XlsxDiagnosticCode::InvalidPartName,
95+
problem,
96+
None,
97+
None,
98+
Some(&raw_name),
99+
);
100+
}
92101
if let Some(existing) = entries.get_mut(&part_name) {
93102
diagnostics.violation(
94103
XlsxDiagnosticCode::EquivalentPartName,
@@ -237,6 +246,14 @@ impl<R: Read + Seek> OpcPackage<R> {
237246
.or_else(|| targets.iter().position(|target| target == &canonical))
238247
.unwrap_or(0);
239248
let workbook = targets.remove(preferred);
249+
// A relationship pointing at a non-workbook part is more
250+
// likely wrong than the content types are, so let the
251+
// content-type scan have the last word before giving up.
252+
if !self.has_workbook_content_type(&workbook) {
253+
if let Ok(fallback) = self.discover_workbook_fallback() {
254+
return Ok(fallback);
255+
}
256+
}
240257
self.validate_workbook_content_type(&workbook)?;
241258
return Ok(workbook);
242259
}

crates/duke-sheets-xlsx/src/opc/part_name.rs

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@ pub(crate) struct PartName(String);
99
impl PartName {
1010
pub(crate) fn new(value: impl Into<String>) -> XlsxResult<Self> {
1111
let value = value.into();
12-
validate_part_name(&value)?;
12+
validate_structure(&value)?;
13+
validate_conformance(&value)?;
14+
Ok(Self(value))
15+
}
16+
17+
/// Accept a name that addresses a part unambiguously but breaks
18+
/// ECMA-376 Part 2 §6.2.2.2 character rules, so Compatible mode can
19+
/// still read parts that non-conforming producers emit (spaces,
20+
/// brackets, redundant percent-encoding).
21+
pub(crate) fn new_lenient(value: impl Into<String>) -> XlsxResult<Self> {
22+
let value = value.into();
23+
validate_structure(&value)?;
1324
Ok(Self(value))
1425
}
1526

@@ -24,7 +35,18 @@ impl PartName {
2435
)));
2536
}
2637
let normalized = value.replace('\\', "/");
27-
Self::new(format!("/{}", normalized.trim_start_matches('/')))
38+
let name = format!("/{}", normalized.trim_start_matches('/'));
39+
if compatible {
40+
Self::new_lenient(name)
41+
} else {
42+
Self::new(name)
43+
}
44+
}
45+
46+
/// Describe how this name breaks the OPC character rules, for
47+
/// diagnostics when it was accepted leniently.
48+
pub(crate) fn conformance_error(&self) -> Option<String> {
49+
validate_conformance(&self.0).err().map(|e| e.to_string())
2850
}
2951

3052
pub(crate) fn as_str(&self) -> &str {
@@ -57,7 +79,9 @@ impl PartName {
5779
} else {
5880
format!("{parent}/_rels/{file}.rels")
5981
};
60-
Self::new(path)
82+
// Conformance was already decided for the owner; the added
83+
// segments are always valid, so never re-reject here.
84+
Self::new_lenient(path)
6185
}
6286
}
6387

@@ -83,19 +107,33 @@ impl std::fmt::Display for PartName {
83107
}
84108
}
85109

86-
fn validate_part_name(value: &str) -> XlsxResult<()> {
110+
/// Rules that must hold for a name to address one part unambiguously.
111+
/// Violating these is unrecoverable in any policy.
112+
fn validate_structure(value: &str) -> XlsxResult<()> {
87113
if value == "/" || !value.starts_with('/') || value.contains(['?', '#', '\\']) {
88114
return Err(XlsxError::InvalidFormat(format!(
89115
"invalid OPC part name: {value}"
90116
)));
91117
}
92-
93118
for segment in value[1..].split('/') {
94-
if segment.is_empty() || matches!(segment, "." | "..") || segment.ends_with('.') {
119+
if segment.is_empty() || matches!(segment, "." | "..") {
95120
return Err(XlsxError::InvalidFormat(format!(
96121
"invalid OPC part name segment in {value}"
97122
)));
98123
}
124+
}
125+
Ok(())
126+
}
127+
128+
/// ECMA-376 Part 2 §6.2.2.2 character and encoding rules. Breaking these
129+
/// makes a package non-conforming without making the name ambiguous.
130+
fn validate_conformance(value: &str) -> XlsxResult<()> {
131+
for segment in value[1..].split('/') {
132+
if segment.ends_with('.') {
133+
return Err(XlsxError::InvalidFormat(format!(
134+
"OPC part name segment ends with a dot in {value}"
135+
)));
136+
}
99137
if segment.chars().any(|character| {
100138
character.is_control()
101139
|| character == ' '
@@ -236,7 +274,12 @@ pub(crate) fn resolve_internal_target_with_policy(
236274
"relationship target does not name a package part: target={target}"
237275
)));
238276
}
239-
PartName::new(format!("/{}", parts.join("/")))
277+
let resolved = format!("/{}", parts.join("/"));
278+
if compatible {
279+
PartName::new_lenient(resolved)
280+
} else {
281+
PartName::new(resolved)
282+
}
240283
}
241284

242285
fn decode_percent_encoded_unreserved(value: &str) -> String {

crates/duke-sheets-xlsx/src/reader/mod.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use quick_xml::reader::Reader;
1111
use crate::error::{XlsxError, XlsxResult};
1212
use crate::opc::{
1313
resolve_internal_target, ContentTypeExpectation, OpcPackage, PartName, Relationship,
14-
RelationshipKind, RelationshipSet, XlsxDiagnosticCode, XlsxPackagePolicy,
14+
RelationshipKind, RelationshipSet, XlsxDiagnosticCode, XlsxDiagnosticSeverity,
15+
XlsxPackagePolicy,
1516
};
1617
use crate::styles::{
1718
read_styles_xml, register_roundtrip_style_data, register_roundtrip_theme_data, ParsedStyles,
@@ -215,9 +216,9 @@ fn resolve_pic_image<R: Read + Seek>(
215216
};
216217
let image_rel_id = image.media_path.clone();
217218
if let Some(rel) = drawing_rels.get(&image_rel_id) {
218-
let Some(path) = rel.internal_path() else {
219-
return Ok(image);
220-
};
219+
// An external or unresolvable target keeps its raw URI so the
220+
// model carries the link rather than a stale relationship id.
221+
let path = rel.internal_path().unwrap_or_else(|| rel.target());
221222
let ext = path.rsplit('.').next().unwrap_or("");
222223
if let Some(fmt) = duke_sheets_chart::ImageFormat::from_extension(ext) {
223224
image.format = fmt;
@@ -232,9 +233,7 @@ fn resolve_pic_image<R: Read + Seek>(
232233
}
233234
if let Some(svg_rel_id) = image.svg_media_path.clone() {
234235
if let Some(rel) = drawing_rels.get(svg_rel_id.as_str()) {
235-
let Some(path) = rel.internal_path() else {
236-
return Ok(image);
237-
};
236+
let path = rel.internal_path().unwrap_or_else(|| rel.target());
238237
image.svg_media_path = Some(path.to_string());
239238
if let Some(mut f) = package.open_related_part(rel)? {
240239
let mut buf = Vec::new();
@@ -487,7 +486,17 @@ pub struct XlsxReader;
487486

488487
fn log_package_diagnostics(diagnostics: &[crate::opc::XlsxDiagnostic]) {
489488
for diagnostic in diagnostics {
490-
log::warn!("{}", diagnostic.message);
489+
let code = diagnostic.code;
490+
let source = diagnostic.source_part.as_deref().unwrap_or("/");
491+
let message = &diagnostic.message;
492+
match diagnostic.severity {
493+
XlsxDiagnosticSeverity::Recovery => {
494+
log::info!("{code:?} in {source}: {message}");
495+
}
496+
XlsxDiagnosticSeverity::Warning => {
497+
log::warn!("{code:?} in {source}: {message}");
498+
}
499+
}
491500
}
492501
}
493502

0 commit comments

Comments
 (0)