Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions apps/obc-web-assemble/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ console_error_panic_hook = "0.1"

[dev-dependencies]
obc-reader = { path = "../../firmware/obc-reader" }
obcm-testkit = { path = "../../host/obcm-testkit" }
# The fixture regenerator (`tests/fixture.rs`, `#[ignore]`d) drives the **real cutter** to write the
# checked-in cell tree, exactly as `obcm-assemble`'s own oracle does. A dev-dep only: obc-pack
# carries libGEOS and must never enter this bridge's build graph, let alone the wasm one.
Expand Down
9 changes: 8 additions & 1 deletion docs/content/software/formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,14 @@ A zero terrain offset and length mean that the map has no terrain.
A zero landmark offset and length mean that it has no landmark section.

Landmark records form a bounded latitude index. Each record holds its QID, category,
actual article language, display coordinate and an optional explicit OSM approach.
display coordinate and an optional explicit OSM approach. A self-contained article bundle stores
every usable text version in the supported UI languages: English, German, French and Spanish.
Each version has its own source credits; all versions share one optional photo.

The reader selects the device UI language, then English, then the default language stored by the
baker. The baker prefers a known local language from captured administrative or country claims.
If these facts do not resolve the choice, it uses the fixed supported-language order. Changing
the device language selects another installed text version without a new map download.
Text, source credits and independent compressed photos stay in the map object.
The reader fetches these payloads only after selection. Each photo is a lossless
216 × 240 RGB222 image with a 4 KiB DEFLATE history window. Decode steps write to
Expand Down
92 changes: 66 additions & 26 deletions firmware/obc-app/src/landmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,12 @@ pub struct Landmarks {
pub name: heapless::String<256>,
pub text: heapless::String<MAX_PAGE_BYTES>,
pub record: Option<LandmarkRecord>,
pub article: Option<obc_formats::articles::ArticleVariant>,
pub generation: Option<u32>,
pub more: bool,
query: Option<LandmarkQuery>,
after: Option<LandmarkKey>,
loaded: Option<(usize, bool, u16)>,
loaded: Option<(usize, bool, u16, [u8; 2])>,
article_pages: u16,
}
impl Landmarks {
Expand All @@ -63,6 +64,7 @@ impl Landmarks {
name: heapless::String::new(),
text: heapless::String::new(),
record: None,
article: None,
generation: None,
more: false,
query: None,
Expand All @@ -86,25 +88,28 @@ impl Landmarks {
self.reading = false;
self.loaded = None;
self.record = None;
self.article = None;
self.name.clear();
self.text.clear();
self.status = Status::Loading;
}
pub(crate) fn invalidate_selection(&mut self) {
self.loaded = None;
self.record = None;
self.article = None;
}
pub(crate) fn invalidate(&mut self) {
self.status = Status::Stale;
self.record = None;
self.article = None;
self.loaded = None;
self.text.clear();
}
pub(crate) fn selection(&self) -> Option<Selection> {
let row = self.selected()?;
Some(Selection { qid: row.key.qid, record_index: row.index, map_generation: self.generation? })
}
pub(crate) fn read_step(&mut self, reader: &Reader, sources: bool) -> Result<(), Error> {
pub(crate) fn read_step(&mut self, reader: &Reader, sources: bool, language: [u8; 2]) -> Result<(), Error> {
if Some(reader.generation()) != self.generation {
self.invalidate();
return Ok(());
Expand Down Expand Up @@ -143,7 +148,11 @@ impl Landmarks {
let Some(row) = self.selected().copied() else {
return Ok(());
};
let requested = (self.selected, sources, if sources { self.source_page } else { self.page });
if self.loaded.is_some_and(|loaded| loaded.3 != language) {
self.page = 0;
self.source_page = 0;
}
let requested = (self.selected, sources, if sources { self.source_page } else { self.page }, language);
if self.loaded == Some(requested) {
return Ok(());
}
Expand All @@ -152,14 +161,15 @@ impl Landmarks {
self.invalidate();
return Ok(());
}
let article = directory.article(&section, &record, language)?;
self.name.clear();
let mut name = [0; MAX_NAME_BYTES as usize];
self.name.push_str(directory.name(&section, &record, &mut name)?).map_err(|_| Error::BadOffset)?;
if !self.name.chars().all(|c| matches!(c,' '..='~'|'\u{a0}'..='\u{17f}')) {
return Err(Error::BadOffset);
}
self.text.clear();
self.article_pages = credit_count(&directory, &section, record.article)?;
self.article_pages = credit_count(&directory, &section, article.attribution)?;
let photo_credits = if record.photo_attribution.is_absent()
|| self.record.is_some_and(|r| r.qid == record.qid && r.photo_attribution.is_absent())
{
Expand All @@ -182,10 +192,10 @@ impl Landmarks {
MAX_ATTRIBUTION_BYTES,
)
} else {
(record.article, self.source_page + 4, self.article_pages + 4, MAX_ATTRIBUTION_BYTES)
(article.attribution, self.source_page + 4, self.article_pages + 4, MAX_ATTRIBUTION_BYTES)
}
} else {
(record.text, self.page.min(record.text_pages as u16 - 1), record.text_pages as u16, MAX_TEXT_BYTES)
(article.text, self.page.min(article.text_pages as u16 - 1), article.text_pages as u16, MAX_TEXT_BYTES)
};
let mut bytes = [0; MAX_PAGE_BYTES];
let result = read_display_page(&directory, &section, reference, limit, count, index, &mut bytes);
Expand All @@ -198,7 +208,7 @@ impl Landmarks {
read_display_page(
&directory,
&section,
record.article,
article.attribution,
MAX_ATTRIBUTION_BYTES,
self.article_pages + 4,
4,
Expand All @@ -209,6 +219,7 @@ impl Landmarks {
};
self.text.push_str(text).map_err(|_| Error::BadOffset)?;
self.record = Some(record);
self.article = Some(article);
self.loaded = Some(requested);
Ok(())
}
Expand Down Expand Up @@ -282,6 +293,7 @@ impl crate::App {
return;
}
let sources = matches!(screen, Screen::LandmarkSources(_));
let language = self.settings().language.article_code();
let state = &mut self.ui.landmarks;
if matches!(
state.status,
Expand All @@ -303,7 +315,7 @@ impl crate::App {
state.generation = Some(reader.generation());
}
let before = state.loaded;
if let Err(error) = state.read_step(reader, sources) {
if let Err(error) = state.read_step(reader, sources, language) {
state.status = match error {
Error::BadVersion => Status::Unsupported,
_ if state.status == Status::Partial => Status::Partial,
Expand All @@ -312,6 +324,7 @@ impl crate::App {
state.loaded = None;
state.text.clear();
state.record = None;
state.article = None;
}
if let Some(record) = state.record {
let source = record.osm.map_or(0, |m| m.source.0);
Expand Down Expand Up @@ -375,14 +388,20 @@ mod tests {
reference
};
let name = append("Ruin ä".as_bytes());
let text = append(&fields(&[
let text = &[
"First source page.",
"Second source
page.",
]));
];
let mut credit_fields = vec!["A", "URL", "License", "License URL"];
credit_fields.extend_from_slice(credits);
let article = append(&fields(&credit_fields));
let articles = append(&obcm_testkit::articles::bundle(
*b"de",
&[
(*b"de", text, &credit_fields),
(*b"es", &["Una ruina."], &["ES URL", "42", "License", "Autores", "Crédito español."]),
],
));
let (photo, photo_attribution) = if photo_credits.is_empty() {
(ContentRef::default(), ContentRef::default())
} else {
Expand All @@ -397,13 +416,10 @@ page.",
lon: 0,
lat: 0,
category: 2,
language: *b"de",
text_pages: 2,
hours_ref: obcm::POI_HOURS_REF_NONE,
osm: None,
name,
text,
article,
articles,
photo,
photo_attribution,
};
Expand All @@ -412,6 +428,7 @@ page.",
}
section[..4].copy_from_slice(&(count as u32).to_le_bytes());
section[4..6].copy_from_slice(&(RECORD_LEN as u16).to_le_bytes());
section[6..8].copy_from_slice(&SECTION_VERSION.to_le_bytes());
section[8..12].copy_from_slice(&(payload as u32).to_le_bytes());
let len = section.len() as u32;
section[12..16].copy_from_slice(&len.to_le_bytes());
Expand All @@ -433,33 +450,33 @@ page.",
let mut state = Landmarks::new();
state.generation = Some(reader.generation());
state.restart(false);
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.rows.iter().map(|r| r.key.qid).collect::<Vec<_>>(), [1, 2, 3, 4]);
assert!(state.more);
assert_eq!(&*state.name, "Ruin ä");
state.selected = 2;
state.reading = true;
state.page = 1;
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
let before = state.text.clone();
assert_eq!(
&*before,
"Second source
page."
);
state.source_page = 1;
state.read_step(&reader, true).unwrap();
state.read_step(&reader, true, *b"en").unwrap();
assert_eq!(&*state.text, "Credit page two.");
assert_eq!(state.source_pages, 2);
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.text, before);
assert_eq!(state.selected().unwrap().key.qid, 3);
state.restart(true);
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.rows.iter().map(|r| r.key.qid).collect::<Vec<_>>(), [5, 6, 7]);
assert!(!state.more);
state.generation = Some(reader.generation().wrapping_add(1));
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.status, Status::Stale);
assert!(state.text.is_empty());
assert!(state.record.is_none());
Expand All @@ -469,6 +486,29 @@ page."
);
}
#[test]
fn changing_ui_language_reloads_text_and_credits_and_resets_page() {
let bytes = map();
let source = SliceSource(&bytes);
let tables = MapTables::parse(&source).unwrap();
let cache = MapCache::new_boxed();
let reader = Reader::new(&source, &tables, &cache);
let mut state = Landmarks::new();
state.generation = Some(reader.generation());
state.restart(false);
state.read_step(&reader, false, *b"de").unwrap();
state.page = 1;
state.read_step(&reader, false, *b"de").unwrap();
state.read_step(&reader, false, *b"es").unwrap();
assert_eq!(state.page, 0);
assert_eq!(state.text.as_str(), "Una ruina.");
assert_eq!(state.article.unwrap().language, *b"es");
state.read_step(&reader, true, *b"es").unwrap();
assert_eq!(state.text.as_str(), "Crédito español.");
state.read_step(&reader, false, *b"fr").unwrap();
assert_eq!(state.article.unwrap().language, *b"de", "no English: use the baked default");
assert_eq!(state.text.as_str(), "First source page.");
}
#[test]
fn full_attribution_budget_keeps_the_last_page_accessible() {
let pages: Vec<_> = (0..MAX_CREDIT_PAGES).map(|i| std::format!("Credit page {i}")).collect();
let refs: Vec<_> = pages.iter().map(std::string::String::as_str).collect();
Expand All @@ -480,16 +520,16 @@ page."
let mut state = Landmarks::new();
state.generation = Some(reader.generation());
state.restart(false);
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.source_pages, MAX_CREDIT_PAGES);
state.source_page = MAX_CREDIT_PAGES - 1;
state.read_step(&reader, true).unwrap();
state.read_step(&reader, true, *b"en").unwrap();
assert_eq!(state.text.as_str(), "Credit page 255");
state.selected = 1;
state.invalidate_selection();
state.selected = 0;
state.invalidate_selection();
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert!(state.record.is_some(), "returning selection reloads its identity");
}
#[test]
Expand All @@ -510,10 +550,10 @@ page."
let mut state = Landmarks::new();
state.generation = Some(reader.generation());
state.restart(false);
state.read_step(&reader, false).unwrap();
state.read_step(&reader, false, *b"en").unwrap();
assert_eq!(state.text.as_str(), "First source page.");
assert!(state.record.unwrap().photo.is_absent());
state.read_step(&reader, true).unwrap();
state.read_step(&reader, true, *b"en").unwrap();
assert_eq!(state.text.as_str(), "Credit page one.");
}
#[test]
Expand Down
8 changes: 4 additions & 4 deletions firmware/obc-app/src/screen/landmark_photo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ impl LandmarkPhotoScreen {
if self.linked {
match gesture {
Gesture::Step(n) => {
if let Some(record) = cx.landmarks.record {
cx.landmarks.page = if n < 0 { record.text_pages as u16 - 1 } else { 0 };
if let Some(article) = cx.landmarks.article {
cx.landmarks.page = if n < 0 { article.text_pages as u16 - 1 } else { 0 };
}
return Transition::Pop;
}
Expand Down Expand Up @@ -108,8 +108,8 @@ impl LandmarkPhotoScreen {
S: MapScene,
{
cv.clear(PARCHMENT);
let page = self.linked.then_some(rx.landmarks.record).flatten().map(|record| {
let total = record.text_pages as u16 + 1;
let page = self.linked.then_some(rx.landmarks.article).flatten().map(|article| {
let total = article.text_pages as u16 + 1;
(total, total)
});
super::landmarks::header(cv, self.title.as_str(), page, None);
Expand Down
14 changes: 7 additions & 7 deletions firmware/obc-app/src/screen/landmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ impl LandmarksScreen {
let Some(record) = state.record else {
return Transition::None;
};
let count = record.text_pages as usize + usize::from(!record.photo.is_absent());
let Some(article) = state.article else {
return Transition::None;
};
let count = article.text_pages as usize + usize::from(!record.photo.is_absent());
let next = list::step_selection(state.page as usize, n, count);
if next == record.text_pages as usize {
if next == article.text_pages as usize {
if let Some(selection) = state.selection() {
return Transition::Push(Screen::LandmarkPhoto(
super::LandmarkPhotoScreen::new(selection, &state.name).linked(),
Expand Down Expand Up @@ -209,7 +212,7 @@ where
if sources {
(state.source_page + 1, state.source_pages)
} else {
(state.page + 1, record.text_pages as u16 + u16::from(!record.photo.is_absent()))
(state.page + 1, state.article.map_or(0, |a| a.text_pages as u16) + u16::from(!record.photo.is_absent()))
}
});
header(
Expand Down Expand Up @@ -332,13 +335,10 @@ mod tests {
lon: 0,
lat: 0,
category: 2,
language: *b"en",
text_pages: 1,
hours_ref: 0,
osm: None,
name: ContentRef::default(),
text: ContentRef::default(),
article: ContentRef::default(),
articles: ContentRef::default(),
photo: ContentRef::default(),
photo_attribution: ContentRef::default(),
});
Expand Down
Loading
Loading