Skip to content

Commit 4295c55

Browse files
committed
fixes
1 parent c4c5ce8 commit 4295c55

97 files changed

Lines changed: 16362 additions & 66 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
.idea
2+
13
# Sensitive or work-in-progress staff that should not be committed
24
/samples/
35
/research/

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **SQL-expression fields.** Decodes `{%name}` SQL-expression field references.
12+
- **Dynamic parameters.** Recognises dynamic (list-of-values) parameters and reports their editing flags accordingly.
13+
- **Top N / Bottom N group sorts.** Decodes group summary sorts and renders their summary sort expression and direction.
14+
- **Percentage summaries.** Decodes percentage summaries (`PercentOfSum (…)`, etc.).
15+
- **Running-total conditions.** Decodes running-total reset and evaluation conditions (`OnChangeOfField` / `OnFormula`).
16+
- **Cross-section boxes.** Resolves a box that spans into a later section, reporting its end section and bottom edge.
17+
- **Dynamic image locations.** Decodes a picture object's dynamic graphic-location formula, and its `EnableCanGrow` flag.
18+
- **Subreport on-demand flag.** Decodes a subreport's `EnableOnDemand` flag.
19+
20+
### Fixed
21+
22+
- **Table aliases with spaces.** Aliases whose table name contains spaces (which Crystal substitutes with underscores)
23+
now match correctly, fixing the alias and the field long-names and formula forms derived from it.
24+
- **Range parameter current values.** A range (non-discrete) current value now sets `HasCurrentValue`.
25+
- **Summary result types.** `Maximum` / `Minimum` summaries report the summarized field's own type; a Currency running
26+
total reports a Number result, matching the engine.
27+
- **Negative line heights.** A line drawn bottom-to-top reports its height as a magnitude.
28+
- **Cross-tab keep-together.** A cross-tab no longer inherits the object-level keep-together flag.
29+
- **Field use counts.** Corrects use-count totals for summary-sorted groups.
30+
31+
## [0.0.0]
32+
933
The initial release: a pure-Rust reader for SAP Crystal Reports `.rpt` files, with no dependency on the SAP runtime, a
1034
database connection, or any Windows component.
1135

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# rpt-rs
22

3+
> ⚠️This entire project has unstable API and is very experimental! Expect major refactorings and breaking changes moving
4+
> forward. If you need stability - fork this project. There is even a chance that I will lost interest in this. You are
5+
> always welcome to fork this repo and work on it on your own.
6+
37
[![License: MPL-2.0](https://img.shields.io/badge/license-MPL--2.0-blue.svg)](LICENSE)
48
![Rust 1.85+](https://img.shields.io/badge/rust-1.85%2B-orange.svg)
59

crates/rpt-engine/src/lib.rs

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -361,11 +361,26 @@ pub fn summary_fields(report: &Report) -> Vec<SummaryFieldDef> {
361361
"CrystalDecisions.CrystalReports.Engine.DatabaseFieldDefinition"
362362
}
363363
.to_string();
364+
// The Operation is the base aggregation. A percentage summary leads with `PercentOf<Op>`
365+
// but its Operation is the underlying op (`Sum`) — percentage is a display mode, not a
366+
// distinct SummaryOperation.
367+
let op_token = p.ds.split(" (").next().unwrap_or("").trim();
368+
let operation = op_token
369+
.strip_prefix("PercentOf")
370+
.unwrap_or(op_token)
371+
.to_string();
372+
// Maximum / Minimum return the summarized field's own type (the result is one of the
373+
// input values), unlike Sum/Average which coerce to Number/Currency. Use the summarized
374+
// DB field's declared type (the placed object's value_type reports the coerced Number).
375+
let vt = match operation.as_str() {
376+
"Maximum" | "Minimum" => summarized_db_field_type(&groups, report).unwrap_or(p.vt),
377+
_ => p.vt,
378+
};
364379
SummaryFieldDef {
365380
formula_name: p.ds.to_string(),
366-
operation: p.ds.split(" (").next().unwrap_or("").trim().to_string(),
367-
value_type: format!("{:?}Field", p.vt),
368-
number_of_bytes: summary_number_of_bytes(p.vt, &groups, report),
381+
operation,
382+
value_type: format!("{vt:?}Field"),
383+
number_of_bytes: summary_number_of_bytes(vt, &groups, report),
369384
summarized_field_type,
370385
grouped: !p.scope.is_empty(),
371386
named: p.named,
@@ -393,6 +408,22 @@ fn brace_groups(s: &str) -> Vec<&str> {
393408
out
394409
}
395410

411+
/// The declared type of the summary's summarized field (`groups[0]`, e.g. `{Command.days}`) when it
412+
/// is a database field; `None` for a formula operand or an unresolved field.
413+
fn summarized_db_field_type(groups: &[&str], report: &Report) -> Option<FieldValueType> {
414+
let name = groups
415+
.first()?
416+
.trim_start_matches('{')
417+
.trim_end_matches('}');
418+
report
419+
.database
420+
.tables
421+
.iter()
422+
.flat_map(|t| &t.data_fields)
423+
.find(|f| f.long_name.as_deref().unwrap_or(&f.name) == name)
424+
.map(|f| f.value_type)
425+
}
426+
396427
/// `NumberOfBytes` for a summary's result type: the intrinsic width, except a string result uses the
397428
/// summarized field's declared length (the result record's length byte is unreliable for strings).
398429
fn summary_number_of_bytes(vt: FieldValueType, groups: &[&str], report: &Report) -> i32 {
@@ -537,7 +568,55 @@ fn count_report(counts: &mut HashMap<String, i32>, field_refs: &[String], r: &Re
537568
for g in &r.data_definition.groups {
538569
let key = format!("{{{}}}", g.condition_field);
539570
if field_refs.contains(&key) {
540-
*counts.entry(key).or_default() += 1;
571+
// A group registers its DB condition field twice: as the group condition and as the
572+
// group sort (both from the same `0xe5`). The sort is normally counted via `record_sorts`
573+
// below. A Top N / Bottom N group sorts by a summary expression instead, so its
574+
// `record_sorts` entry no longer names the field — count the sort reference here when the
575+
// sort field differs.
576+
*counts.entry(key.clone()).or_default() += 1;
577+
// A summary-sorted group holds, on its condition field, the group-sort reference plus one
578+
// reference per group-scoped construct: the group-name definition, each placed GroupName
579+
// field object, and each group summary whose group-by (2nd) arg is this field. A plain
580+
// group does not reach here.
581+
if g.sort.field != g.condition_field {
582+
// The group-sort reference held on the condition field (condition + sort).
583+
*counts.entry(key.clone()).or_default() += 1;
584+
*counts.entry(key.clone()).or_default() += 1; // GroupName definition
585+
let gn_ds = format!("GroupName ({key})");
586+
let placed_group_names = r
587+
.report_definition
588+
.areas
589+
.iter()
590+
.flat_map(|a| &a.sections)
591+
.flat_map(|s| &s.objects)
592+
.filter(
593+
|o| matches!(&o.kind, ReportObjectKind::Field(f) if f.data_source == gn_ds),
594+
)
595+
.count();
596+
let summary_suffix = format!(", {key})");
597+
let group_summaries = summary_fields(r)
598+
.iter()
599+
.filter(|s| s.formula_name.ends_with(&summary_suffix))
600+
.count();
601+
*counts.entry(key).or_default() += (placed_group_names + group_summaries) as i32;
602+
603+
// The Top N sort basis is a summary (`g.sort.field`) referencing its summarized
604+
// (1st-arg) field. If the group displays that same summary it is already counted via
605+
// `summary_fields`; if it displays a different one (e.g. a percentage, while the sort
606+
// basis stays a plain `Sum`), that sort-basis summary is a separate, otherwise
607+
// uncounted reference → +1.
608+
let sort_summary = g.sort.field.as_str();
609+
let already_counted = summary_fields(r)
610+
.iter()
611+
.any(|s| s.formula_name == sort_summary);
612+
if !already_counted {
613+
if let Some(first) = brace_groups(sort_summary).into_iter().next() {
614+
if let Some(fr) = field_refs.iter().find(|fr| fr.as_str() == first) {
615+
*counts.entry(fr.clone()).or_default() += 1;
616+
}
617+
}
618+
}
619+
}
541620
}
542621
}
543622
for s in &r.data_definition.record_sorts {
@@ -633,7 +712,14 @@ fn count_report(counts: &mut HashMap<String, i32>, field_refs: &[String], r: &Re
633712
if let Some(g) = r.data_definition.groups.first() {
634713
let key = format!("{{{}}}", g.condition_field);
635714
if field_refs.contains(&key) {
636-
*counts.entry(key).or_default() += 1;
715+
// A reused-group chart adds +1 over a plain group. On a summary-sorted
716+
// group it instead binds a full category (condition + sort) → +2.
717+
let n = if g.sort.field != g.condition_field {
718+
2
719+
} else {
720+
1
721+
};
722+
*counts.entry(key).or_default() += n;
637723
}
638724
}
639725
}

crates/rpt-to-xml/src/objects.rs

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use crate::colors::{write_color, BLACK};
88
use crate::util::{b, escape, escape_text};
99

1010
/// `<ObjectFormatConditionFormulas>` attribute order (subset of the SDK enum we decode).
11-
const OBJECT_COND_ORDER: &[&str] = &["EnableSuppress", "DisplayString"];
11+
const OBJECT_COND_ORDER: &[&str] = &["EnableSuppress", "DisplayString", "GraphicLocation"];
1212
/// `<SectionAreaConditionFormulas>` attribute order (SDK enum order).
1313
const SECTION_COND_ORDER: &[&str] = &["EnableSuppress", "EnableNewPageAfter", "BackgroundColor"];
1414
/// `<FontColorConditionFormulas>` attribute order (SDK enum order).
@@ -152,7 +152,9 @@ pub(crate) fn write_object(o: &mut String, obj: &ReportObject, section_name: &st
152152
r.top.0,
153153
r.left.0,
154154
r.width.0,
155-
r.height.0
155+
// A line drawn bottom-to-top stores a negative (signed `0x9e`) height; the engine reports
156+
// the magnitude. Every other object has a non-negative height, so `abs` is a no-op there.
157+
r.height.0.abs()
156158
)
157159
};
158160
// A plain text object's default alignment renders as LeftAlign; a field object keeps
@@ -228,8 +230,18 @@ pub(crate) fn write_object(o: &mut String, obj: &ReportObject, section_name: &st
228230
// same element shape: degenerate-rectangle attrs plus shape format children.
229231
let is_box = matches!(&obj.kind, ReportObjectKind::Box(_));
230232
let tag = if is_box { "BoxObject" } else { "LineObject" };
231-
let bottom = r.top.0 + r.height.0;
232233
let right = r.left.0 + r.width.0;
234+
// A cross-section box uses its resolved end section, and its Bottom is the opener's
235+
// end-relative value (not top+height, which would span sections). A normal box/line ends
236+
// in its own section with Bottom = top + height.
237+
let (end_section, bottom) = match &obj.kind {
238+
ReportObjectKind::Box(bx) if !bx.end_section_name.is_empty() => {
239+
(bx.end_section_name.as_str(), bx.shape.bottom.0)
240+
}
241+
// A bottom-to-top line (negative height) ends at its anchor top, so its Bottom is
242+
// the top; `max(0)` keeps the normal `top + height` for non-negative heights.
243+
_ => (section_name, r.top.0 + r.height.0.max(0)),
244+
};
233245
// LineThickness (twips) is decoded from `0xec` byte 21; line and box are separate
234246
// model variants so the shape is read from whichever applies.
235247
let (line_thickness, extend_to_bottom) = match &obj.kind {
@@ -248,7 +260,7 @@ pub(crate) fn write_object(o: &mut String, obj: &ReportObject, section_name: &st
248260
"{pad}<{tag} {} Bottom=\"{bottom}\" EnableExtendToBottomOfSection=\"{}\" EndSectionName=\"{}\" LineStyle=\"{}\" LineThickness=\"{line_thickness}\" Right=\"{right}\">",
249261
head(tag),
250262
b(extend_to_bottom),
251-
escape(section_name),
263+
escape(end_section),
252264
shape_line_style(&obj.border),
253265
);
254266
if is_box {
@@ -358,11 +370,22 @@ fn write_plain_object(
358370
let pad = " ".repeat(depth);
359371
let _ = writeln!(o, "{pad}<{tag} {head}>");
360372
write_border(o, &obj.border, depth + 1);
373+
// EnableCanGrow is decoded (a dynamic/CanGrow picture sets it True); the rest stay at the
374+
// plain-object defaults.
361375
let _ = writeln!(
362376
o,
363-
"{pad} <ObjectFormat CssClass=\"\" EnableCanGrow=\"False\" EnableCloseAtPageBreak=\"True\" EnableKeepTogether=\"True\" EnableSuppress=\"False\" HorizontalAlignment=\"{align}\" />"
377+
"{pad} <ObjectFormat CssClass=\"\" EnableCanGrow=\"{}\" EnableCloseAtPageBreak=\"True\" EnableKeepTogether=\"True\" EnableSuppress=\"False\" HorizontalAlignment=\"{align}\" />",
378+
b(obj.format.can_grow)
379+
);
380+
// A PictureObject's dynamic graphic-location formula (and any other decoded object-format
381+
// condition) is carried here; empty for plain pictures/charts.
382+
write_condition_formulas(
383+
o,
384+
&format!("{pad} "),
385+
"ObjectFormatConditionFormulas",
386+
OBJECT_COND_ORDER,
387+
&obj.format.condition_formulas,
364388
);
365-
let _ = writeln!(o, "{pad} <ObjectFormatConditionFormulas />");
366389
let _ = writeln!(o, "{pad}</{tag}>");
367390
}
368391

crates/rpt-to-xml/src/xml.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -209,12 +209,18 @@ fn write_report_core(
209209
} else {
210210
o.push_str(" <SortFields>\n");
211211
for s in &dd.record_sorts {
212+
// A plain field reference is brace-wrapped (`{Table.Field}`); a Top N / Bottom N group
213+
// sort already holds a full summary expression (`Sum ({…}, {…})`) — detected by its
214+
// parenthesis — and must not be re-wrapped.
215+
let field = if s.field.contains('(') {
216+
escape(&s.field)
217+
} else {
218+
format!("{{{}}}", escape(&s.field))
219+
};
212220
let _ = writeln!(
213221
o,
214-
" <SortField Field=\"{{{}}}\" SortDirection=\"{:?}\" SortType=\"{:?}\" />",
215-
escape(&s.field),
216-
s.direction,
217-
s.kind
222+
" <SortField Field=\"{}\" SortDirection=\"{:?}\" SortType=\"{:?}\" />",
223+
field, s.direction, s.kind
218224
);
219225
}
220226
o.push_str(" </SortFields>\n");

crates/rpt/src/model/enums.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ sdk_enum!(
3939
}, Other);
4040

4141
sdk_enum!(
42-
/// SDK: `SortDirection`.
43-
SortDirection { AscendingOrder, DescendingOrder, NoSortOrder }, Other);
42+
/// SDK: `SortDirection`. `TopNOrder`/`BottomNOrder` are group Top N / Bottom N sort directions.
43+
SortDirection { AscendingOrder, DescendingOrder, NoSortOrder, TopNOrder, BottomNOrder }, Other);
4444

4545
sdk_enum!(
4646
/// XML `@SortType` — which collection a sort came from.

crates/rpt/src/model/objects.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ pub enum FieldRefKind {
7171
RunningTotal,
7272
/// A parameter field — `{?name}`.
7373
Parameter,
74+
/// A SQL expression field — `{%name}`.
75+
SqlExpression,
7476
/// Anything else; the raw reference is used verbatim.
7577
Unknown,
7678
}
@@ -86,6 +88,7 @@ impl FieldRefKind {
8688
0x04 => Self::GroupName,
8789
0x06 => Self::Parameter,
8890
0x09 => Self::RunningTotal,
91+
0x0a => Self::SqlExpression,
8992
_ => Self::Unknown,
9093
}
9194
}

0 commit comments

Comments
 (0)